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/cli.py ADDED
@@ -0,0 +1,3118 @@
1
+ """CLI entry point for nedviga-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Sequence
10
+
11
+
12
+
13
+ def _package_version() -> str:
14
+ # v0.5.1 контроль качества: дерево репо = источник истины; метаданные установленного
15
+ # диста/egg-info могут устаревать между волнами (ловушка 351-прогона).
16
+ from . import __version__ as source_version
17
+
18
+ return source_version
19
+ from .cian_card import ParseError, parse_card
20
+ from .cian_live import ChallengeError, FetchError, get_card, search
21
+ from .doctor import run_doctor
22
+ from .geo_routing import honest_overclaim, walk_route
23
+ from .models import HonestWalkRoute, ListingCard
24
+ from .outcomes import STATUS_EXIT_CODES, USAGE_ERROR_EXIT, make_envelope
25
+ from .pii import sanitize_contacts
26
+ from .profiles import UnknownProfileError, apply_profile, available_profiles
27
+
28
+
29
+ class _NedvigaArgumentParser(argparse.ArgumentParser):
30
+ """Argparse wrapper that preserves the ApiOutcome contract on usage errors."""
31
+
32
+ def error(self, message: str) -> None: # type: ignore[override]
33
+ self.print_usage(sys.stderr)
34
+ print(f"nedviga-cli: error: {message}", file=sys.stderr)
35
+ envelope = make_envelope(
36
+ status="error",
37
+ reason="usage_error",
38
+ data={"message": message},
39
+ metadata={"engine": "skel", "cached": False, "elapsed_seconds": 0.0},
40
+ )
41
+ print(
42
+ json.dumps(
43
+ envelope.model_dump(mode="json", exclude_none=False),
44
+ ensure_ascii=False,
45
+ indent=2,
46
+ )
47
+ )
48
+ sys.exit(USAGE_ERROR_EXIT)
49
+
50
+
51
+ def _spotlight_external_fields(payload, _src="cian"):
52
+ """P3-2: пометить внешний (сайтовый) текст spotlight-маркерами netguard.
53
+
54
+ Только поля, приходящие с площадки: title / description_text.
55
+ Идемпотентно: уже помеченное не перемечается.
56
+ """
57
+ try:
58
+ from .net_guard import is_spotlighted, spotlight
59
+ except Exception: # noqa: BLE001 — netguard обязателен, но не роняем вывод
60
+ return payload
61
+ if isinstance(payload, dict):
62
+ _src = payload.get("source") or _src
63
+ for key in ("title", "description_text"):
64
+ value = payload.get(key)
65
+ if isinstance(value, str) and value and not is_spotlighted(value):
66
+ payload[key] = spotlight(value, source=_src, data_type="listing_" + key)
67
+ for value in payload.values():
68
+ _spotlight_external_fields(value, _src)
69
+ elif isinstance(payload, list):
70
+ for item in payload:
71
+ _spotlight_external_fields(item, _src)
72
+ return payload
73
+
74
+
75
+ def _dump_envelope(envelope, args: argparse.Namespace) -> str:
76
+ data = envelope.model_dump(mode="json", exclude_none=False)
77
+ if not getattr(args, "no_spotlight", False):
78
+ _spotlight_external_fields(data.get("data"))
79
+ text = json.dumps(
80
+ data,
81
+ ensure_ascii=False,
82
+ indent=2,
83
+ )
84
+ return text
85
+
86
+
87
+ def _write_output(text: str, args: argparse.Namespace) -> None:
88
+ print(text)
89
+ if getattr(args, "json", None) and args.json != "-":
90
+ with open(args.json, "w", encoding="utf-8") as fh:
91
+ fh.write(text)
92
+
93
+
94
+ def _cmd_source(args) -> int:
95
+ """§13 AGPL: лицензия, репозиторий исходников, режим сборки."""
96
+ from ._wm import source_info
97
+ try:
98
+ from . import __version__ as _ver
99
+ except Exception:
100
+ _ver = "0"
101
+ info = source_info(_ver)
102
+ envelope = make_envelope(status="ok", source="source", data=info,
103
+ metadata=_privacy_metadata({"engine": "source"}, args))
104
+ _write_output(_dump_envelope(envelope, args), args)
105
+ return 0
106
+
107
+
108
+ def _privacy_metadata(metadata: dict, args: argparse.Namespace) -> dict:
109
+ try:
110
+ from ._wm import LICENSE as _lic, source_url as _surl
111
+ metadata.setdefault("license", _lic)
112
+ if _surl():
113
+ metadata.setdefault("source_url", _surl())
114
+ except Exception:
115
+ pass
116
+ """v0.4.6 F-13: явный маркер открытия контактов в envelope/metadata.
117
+
118
+ ``contacts_open`` присутствует во всех выводах search/export: true только
119
+ при явном ``--with-contacts``, иначе false (клиентский/публичный вывод).
120
+ """
121
+ meta = dict(metadata)
122
+ meta.setdefault("contacts_open", bool(getattr(args, "with_contacts", False)))
123
+ # v0.5.8: транспортный след (какой egress реально использован)
124
+ try:
125
+ from .net_guard import EGRESS_STATE
126
+ if EGRESS_STATE.get("mode"):
127
+ meta.setdefault("egress_mode", EGRESS_STATE["mode"])
128
+ except Exception:
129
+ pass
130
+ from .roles import current_role
131
+ meta.setdefault("role", getattr(args, "role", current_role()))
132
+ if getattr(args, "_contacts_requested", False):
133
+ meta["warning"] = "contacts_masked_for_role"
134
+ return meta
135
+
136
+
137
+ def _cmd_search(args: argparse.Namespace) -> int:
138
+ source = getattr(args, "source", None)
139
+ if source == "domclick":
140
+ from .sources_domclick import browser_search_domclick
141
+ catalog = getattr(args, "catalog", None)
142
+ inp = getattr(args, "input", None)
143
+ live = bool(getattr(args, "live", False))
144
+ try:
145
+ if inp:
146
+ from .sources_domclick import parse_domclick_text
147
+ cards = parse_domclick_text(Path(inp).read_text(encoding="utf-8"))
148
+ elif live:
149
+ cards = browser_search_domclick(catalog or "", max_n=getattr(args, "max", 20) or 20)
150
+ else:
151
+ envelope = make_envelope(status="error", reason="source_disabled",
152
+ metadata=_privacy_metadata({"engine": "domclick"}, args))
153
+ _write_output(_dump_envelope(envelope, args), args)
154
+ return 3
155
+ except RuntimeError as exc:
156
+ reason = str(exc)
157
+ code = 3 if reason.startswith("source_") else 3
158
+ envelope = make_envelope(status="error", reason=reason.split(":")[0],
159
+ metadata=_privacy_metadata({"engine": "domclick"}, args))
160
+ _write_output(_dump_envelope(envelope, args), args)
161
+ return code
162
+ data = sanitize_contacts(cards)
163
+ envelope = make_envelope(status="ok", source="domclick", data=data,
164
+ metadata=_privacy_metadata({"engine": "domclick", "cached": False, "elapsed_seconds": 0.0}, args))
165
+ _write_output(_dump_envelope(envelope, args), args)
166
+ return 0
167
+
168
+ if source == "avito":
169
+ from .sources_avito import browser_search, parse_livetext
170
+ catalog = getattr(args, "catalog", None)
171
+ live = bool(getattr(args, "live", False))
172
+ inp = getattr(args, "input", None)
173
+ if inp:
174
+ text = Path(inp).read_text(encoding="utf-8")
175
+ from .sources_avito import BLOCK_MARKERS as _BM
176
+ if any(m.lower() in text.lower() for m in _BM):
177
+ envelope = make_envelope(status="error", reason="source_blocked",
178
+ data={"message": "captcha detected"},
179
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
180
+ _write_output(_dump_envelope(envelope, args), args)
181
+ return 3
182
+ cards = parse_livetext(text)
183
+ data = sanitize_contacts(cards)
184
+ envelope = make_envelope(status="ok", source="avito", data=data,
185
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
186
+ _write_output(_dump_envelope(envelope, args), args)
187
+ return 0
188
+ if live:
189
+ try:
190
+ cards = browser_search(catalog, max_n=getattr(args, "max", 20) or 20)
191
+ except RuntimeError as exc:
192
+ envelope = make_envelope(status="error", reason=str(exc),
193
+ data={"message": str(exc)},
194
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
195
+ _write_output(_dump_envelope(envelope, args), args)
196
+ return 3
197
+ data = sanitize_contacts(cards)
198
+ envelope = make_envelope(status="ok", source="avito", data=data,
199
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
200
+ _write_output(_dump_envelope(envelope, args), args)
201
+ return 0
202
+ envelope = make_envelope(status="error", reason="source_disabled",
203
+ data={"message": "egress proxy required"},
204
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
205
+ _write_output(_dump_envelope(envelope, args), args)
206
+ return 3
207
+ if source != "cian":
208
+ envelope = make_envelope(
209
+ status="error",
210
+ reason="unsupported_source",
211
+ source=source,
212
+ data={"message": f"source '{source}' is not supported; use 'cian'"},
213
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
214
+ )
215
+ _write_output(_dump_envelope(envelope, args), args)
216
+ return 3
217
+
218
+ hydrate = bool(getattr(args, "hydrate", False))
219
+ # v0.4.6 (the task brief F-03): именованный профиль задаёт базовые значения,
220
+ # явно переданные CLI-аргументы его переопределяют. Без --profile — как раньше.
221
+ profile_name = getattr(args, "profile", None)
222
+ profile_fields: list[str] = []
223
+ if profile_name:
224
+ try:
225
+ profile_fields = apply_profile(args, profile_name)
226
+ except UnknownProfileError as exc:
227
+ envelope = make_envelope(
228
+ status="error",
229
+ reason="unknown_profile",
230
+ source="cian",
231
+ data={
232
+ "requested_profile": exc.requested,
233
+ "available_profiles": exc.known,
234
+ "message": str(exc),
235
+ },
236
+ metadata=_privacy_metadata(
237
+ {"engine": "native", "cached": False, "elapsed_seconds": 0.0}, args
238
+ ),
239
+ )
240
+ _write_output(_dump_envelope(envelope, args), args)
241
+ return STATUS_EXIT_CODES["error"]
242
+ # v0.4.0 (ТЗ реалтора): клиентские фильтры требовали полных карточек.
243
+ # v0.4.3 (ТЗ the task brief §2): floor_not_first/min_house_floors/no_gas больше
244
+ # НЕ форсируют гидрацию — работают по сниппет-полям до запросов (экономия);
245
+ # forcing-фильтры (гео/строгий МКАД/апартаменты/доли/юридические/торги)
246
+ # остаются: их полей в сниппете нет, корректность важнее экономии.
247
+ detail_filters = (
248
+ getattr(args, "geo_districts", None) is not None
249
+ or getattr(args, "strict_mkad", False)
250
+ or getattr(args, "only_flat", False)
251
+ or getattr(args, "no_shares", False)
252
+ or getattr(args, "exclude_legal_risks", False)
253
+ or getattr(args, "no_auction", False)
254
+ or getattr(args, "only_auction", False)
255
+ )
256
+ if detail_filters:
257
+ hydrate = True
258
+
259
+ min_house_floors = args.min_house_floors
260
+ if getattr(args, "no_gas", False) and min_house_floors is None:
261
+ min_house_floors = 14
262
+
263
+ try:
264
+ cards = search(
265
+ deal_type=args.deal_type,
266
+ rooms=args.rooms,
267
+ price_min=args.price_min,
268
+ price_max=args.price_max,
269
+ kitchen_min=args.kitchen_min,
270
+ floor_not_first=args.floor_not_first,
271
+ min_house_floors=min_house_floors,
272
+ geo_districts=args.geo_districts,
273
+ limit=args.limit,
274
+ page=getattr(args, "page", 1) or 1,
275
+ pages=getattr(args, "pages", None),
276
+ metro_walk_max=getattr(args, "metro_walk_max", None),
277
+ hydrate=hydrate,
278
+ no_auction=bool(getattr(args, "no_auction", False)),
279
+ only_auction=bool(getattr(args, "only_auction", False)),
280
+ workers=getattr(args, "workers", 4) or 4,
281
+ strict_mkad=bool(getattr(args, "strict_mkad", False)),
282
+ only_flat=bool(getattr(args, "only_flat", False)),
283
+ no_shares=bool(getattr(args, "no_shares", False)),
284
+ exclude_legal_risks=bool(getattr(args, "exclude_legal_risks", False)),
285
+ )
286
+ except ChallengeError as exc:
287
+ envelope = make_envelope(
288
+ status="degraded",
289
+ reason=f"challenge_detected: {exc}",
290
+ source="cian",
291
+ data=None,
292
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
293
+ )
294
+ _write_output(_dump_envelope(envelope, args), args)
295
+ return STATUS_EXIT_CODES["degraded"]
296
+ except FetchError as exc:
297
+ envelope = make_envelope(
298
+ status="error",
299
+ reason=f"fetch_error: {exc}",
300
+ source="cian",
301
+ data=None,
302
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
303
+ )
304
+ _write_output(_dump_envelope(envelope, args), args)
305
+ return STATUS_EXIT_CODES["error"]
306
+
307
+ # v0.4.6 (the task brief F-13): fail-closed маскирование телефонов по умолчанию.
308
+ # Сырые контакты — только при явном --with-contacts (маркер contacts_open).
309
+ with_contacts = bool(getattr(args, "with_contacts", False))
310
+ # v0.5.0: автопополнение проекта
311
+ proj = getattr(args, "project", None)
312
+ if proj:
313
+ try:
314
+ from .projects import project_add
315
+ for card in cards:
316
+ project_add(proj, card.id)
317
+ except Exception:
318
+ pass
319
+ data = [card.model_dump(mode="json", exclude_none=False) for card in cards]
320
+ if not with_contacts:
321
+ data = sanitize_contacts(data)
322
+ metadata: dict = {"engine": "native", "cached": False, "elapsed_seconds": 0.0}
323
+ if profile_name:
324
+ metadata["profile"] = profile_name
325
+ metadata["profile_fields"] = profile_fields
326
+ if getattr(args, "suggest_relax", False):
327
+ metadata["hints"] = [] if len(cards) >= 3 else [{"relax": "смягчить фильтры", "adds_n": 0}]
328
+ envelope = make_envelope(
329
+ status="ok",
330
+ source="cian",
331
+ data=data,
332
+ metadata=_privacy_metadata(metadata, args),
333
+ )
334
+ _write_output(_dump_envelope(envelope, args), args)
335
+ return 0
336
+
337
+
338
+ def _embed_walk(card, args):
339
+ """v0.3.2 (--with-walk, ТЗ реалтора): честный маршрут прямо в карточку.
340
+
341
+ Fail-closed: при любой ошибке карточка остаётся валидной,
342
+ причина — в metadata.with_walk.
343
+ """
344
+ lat, lon = card.geo.lat, card.geo.lon
345
+ claimed = card.geo.cian_claimed_metro.station if card.geo.cian_claimed_metro else None
346
+ if lat is None or lon is None:
347
+ return {"status": "degraded", "reason": "NO_COORDINATES"}
348
+ if not claimed:
349
+ return {"status": "degraded", "reason": "NO_CLAIMED_METRO"}
350
+ try:
351
+ route = walk_route(lat, lon, claimed)
352
+ if route.get("status") != "ok":
353
+ return {"status": "degraded", "reason": route.get("reason") or "walk_failed"}
354
+ card.geo.honest_walk_route = HonestWalkRoute(
355
+ target_station=route["target_station"],
356
+ target_vestibule=route["target_vestibule"],
357
+ walk_distance_meters=route["walk_distance_meters"],
358
+ walk_duration_minutes=route["walk_duration_minutes"],
359
+ routing_provider=route["routing_provider"],
360
+ route_verified=route["route_verified"],
361
+ )
362
+ claimed_min = (
363
+ card.geo.cian_claimed_metro.claimed_time_minutes
364
+ if card.geo.cian_claimed_metro
365
+ else None
366
+ )
367
+ if claimed_min:
368
+ card.geo.honest_overclaim = honest_overclaim(
369
+ int(route["walk_duration_minutes"]), int(claimed_min)
370
+ )
371
+ return {"status": "ok", "provider": route["routing_provider"]}
372
+ except Exception as exc:
373
+ return {"status": "degraded", "reason": f"walk_error: {exc}"}
374
+
375
+
376
+ def _cmd_get_card(args: argparse.Namespace) -> int:
377
+ target = getattr(args, "target", None)
378
+ if not target:
379
+ envelope = make_envelope(
380
+ status="error",
381
+ reason="usage_error",
382
+ source="cian",
383
+ data={"message": "missing URL or listing ID"},
384
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
385
+ )
386
+ _write_output(_dump_envelope(envelope, args), args)
387
+ return USAGE_ERROR_EXIT
388
+
389
+ try:
390
+ card = get_card(target)
391
+ except ChallengeError as exc:
392
+ envelope = make_envelope(
393
+ status="degraded",
394
+ reason=f"challenge_detected: {exc}",
395
+ source="cian",
396
+ data=None,
397
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
398
+ )
399
+ _write_output(_dump_envelope(envelope, args), args)
400
+ return STATUS_EXIT_CODES["degraded"]
401
+ except FetchError as exc:
402
+ envelope = make_envelope(
403
+ status="error",
404
+ reason=f"fetch_error: {exc}",
405
+ source="cian",
406
+ data=None,
407
+ metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
408
+ )
409
+ _write_output(_dump_envelope(envelope, args), args)
410
+ return STATUS_EXIT_CODES["error"]
411
+
412
+ walk_note = _embed_walk(card, args) if getattr(args, "with_walk", False) else None
413
+
414
+ with_contacts = bool(getattr(args, "with_contacts", False))
415
+ data = card.model_dump(mode="json", exclude_none=False)
416
+ if not with_contacts:
417
+ data = sanitize_contacts(data)
418
+ metadata = {"engine": "native", "cached": False, "elapsed_seconds": 0.0,
419
+ "with_walk": walk_note}
420
+ metadata = _privacy_metadata(metadata, args)
421
+ envelope = make_envelope(
422
+ status="ok",
423
+ source="cian",
424
+ data=data,
425
+ metadata=metadata,
426
+ )
427
+ _write_output(_dump_envelope(envelope, args), args)
428
+ return 0
429
+
430
+
431
+ def _cmd_export(args: argparse.Namespace) -> int:
432
+ """v0.3.4 (ТЗ реалтора): экспорт карточек в автономный HTML-каталог."""
433
+ from .export_html import export_html
434
+
435
+ inp = getattr(args, "input", None)
436
+ if not inp:
437
+ envelope = make_envelope(
438
+ status="error", reason="usage_error",
439
+ data={"message": "missing --input (JSON file with cards)"},
440
+ metadata={"engine": "export", "cached": False, "elapsed_seconds": 0.0},
441
+ )
442
+ _write_output(_dump_envelope(envelope, args), args)
443
+ return USAGE_ERROR_EXIT
444
+
445
+ try:
446
+ raw = json.loads(Path(inp).read_text(encoding="utf-8"))
447
+ cards = [ListingCard(**item) for item in (raw if isinstance(raw, list) else raw.get("cards", []))]
448
+ except Exception as exc:
449
+ envelope = make_envelope(
450
+ status="error", reason=f"invalid_input: {exc}",
451
+ data={"path": inp},
452
+ metadata={"engine": "export", "cached": False, "elapsed_seconds": 0.0},
453
+ )
454
+ _write_output(_dump_envelope(envelope, args), args)
455
+ return 3
456
+
457
+ fmt = getattr(args, "format", "html") or "html"
458
+ if fmt != "html":
459
+ envelope = make_envelope(
460
+ status="error", reason=f"unsupported_format: {fmt}",
461
+ data={"supported": ["html"]},
462
+ metadata={"engine": "export", "cached": False, "elapsed_seconds": 0.0},
463
+ )
464
+ _write_output(_dump_envelope(envelope, args), args)
465
+ return USAGE_ERROR_EXIT
466
+
467
+ out = getattr(args, "output", None) or "catalog.html"
468
+ embed = bool(getattr(args, "embed_photos", False))
469
+ # v0.4.6 (the task brief F-13): HTML-каталог по умолчанию без сырых телефонов;
470
+ # --with-contacts открывает контакты и помечает вывод в metadata.
471
+ with_contacts = bool(getattr(args, "with_contacts", False))
472
+ try:
473
+ path = export_html(
474
+ cards, Path(out), embed_photos=embed, with_contacts=with_contacts
475
+ )
476
+ except Exception as exc:
477
+ envelope = make_envelope(
478
+ status="error", reason=f"export_failed: {exc}",
479
+ data={"output": out},
480
+ metadata=_privacy_metadata(
481
+ {"engine": "export", "cached": False, "elapsed_seconds": 0.0}, args
482
+ ),
483
+ )
484
+ _write_output(_dump_envelope(envelope, args), args)
485
+ return 3
486
+
487
+ envelope = make_envelope(
488
+ status="ok",
489
+ source="cian",
490
+ data={"output": str(path), "cards": len(cards), "embed_photos": embed},
491
+ metadata=_privacy_metadata(
492
+ {"engine": "export", "cached": False, "elapsed_seconds": 0.0}, args
493
+ ),
494
+ )
495
+ _write_output(_dump_envelope(envelope, args), args)
496
+ return 0
497
+
498
+
499
+ def _load_card_input(target: str) -> ListingCard:
500
+ p = Path(target)
501
+ if p.is_file():
502
+ raw = json.loads(p.read_text(encoding="utf-8"))
503
+ if isinstance(raw, list):
504
+ raw = raw[0] if raw else {}
505
+ if isinstance(raw, dict) and "cards" in raw and isinstance(raw["cards"], list):
506
+ raw = raw["cards"][0] if raw["cards"] else {}
507
+ if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], (list, dict)):
508
+ d = raw["data"]
509
+ if isinstance(d, list):
510
+ d = d[0] if d else {}
511
+ if isinstance(d, dict):
512
+ raw = d
513
+ return ListingCard(**raw)
514
+ # treat as id
515
+ from .cian_live import get_card as _get_card
516
+
517
+ card = _get_card(target)
518
+ return card
519
+
520
+
521
+ def _cmd_card(args: argparse.Namespace) -> int:
522
+ target = getattr(args, "target", None) or getattr(args, "path_or_id", None)
523
+ if not target:
524
+ envelope = make_envelope(
525
+ status="error", reason="usage_error",
526
+ data={"message": "missing card path or id"},
527
+ metadata={"engine": "card", "cached": False, "elapsed_seconds": 0.0},
528
+ )
529
+ _write_output(_dump_envelope(envelope, args), args)
530
+ return USAGE_ERROR_EXIT
531
+
532
+ try:
533
+ card = _load_card_input(target)
534
+ except Exception as exc:
535
+ envelope = make_envelope(
536
+ status="error", reason="conversion_error",
537
+ data={"message": str(exc), "target": target},
538
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
539
+ )
540
+ _write_output(_dump_envelope(envelope, args), args)
541
+ return 3
542
+
543
+ fmt = getattr(args, "format", "html") or "html"
544
+ if fmt not in {"html", "pdf", "telegram", "all"}:
545
+ envelope = make_envelope(
546
+ status="error", reason="unsupported_format",
547
+ data={"supported": ["html", "pdf", "telegram", "all"]},
548
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
549
+ )
550
+ _write_output(_dump_envelope(envelope, args), args)
551
+ return USAGE_ERROR_EXIT
552
+
553
+ with_contacts = bool(getattr(args, "with_contacts", False))
554
+ out_dir = Path(getattr(args, "out", None) or getattr(args, "output", None) or ".")
555
+ try:
556
+ out_dir.mkdir(parents=True, exist_ok=True)
557
+ except Exception as exc:
558
+ envelope = make_envelope(
559
+ status="error", reason=f"output_error: {exc}",
560
+ data={"out": str(out_dir)},
561
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
562
+ )
563
+ _write_output(_dump_envelope(envelope, args), args)
564
+ return 3
565
+
566
+ stem = Path(target).stem if Path(target).is_file() else str(target)
567
+ stem = stem or "card"
568
+
569
+ from .family_card import build_family_card, render_html, render_telegram
570
+
571
+ fc = build_family_card(card, with_contacts=with_contacts)
572
+
573
+ files: list[str] = []
574
+ primary_output: Any = None
575
+ text_output: str | None = None
576
+
577
+ try:
578
+ if fmt in {"html", "all"}:
579
+ html_path = out_dir / f"{stem}.html"
580
+ html_path.write_text(render_html(fc, with_contacts=with_contacts), encoding="utf-8")
581
+ files.append(str(html_path))
582
+ if primary_output is None and fmt == "html":
583
+ primary_output = str(html_path)
584
+ if fmt in {"telegram", "all"}:
585
+ tg_text = render_telegram(fc, with_contacts=with_contacts)
586
+ text_output = tg_text
587
+ tg_path = out_dir / f"{stem}.txt"
588
+ tg_path.write_text(tg_text, encoding="utf-8")
589
+ files.append(str(tg_path))
590
+ if primary_output is None and fmt == "telegram":
591
+ primary_output = str(tg_path)
592
+ if fmt in {"pdf", "all"}:
593
+ try:
594
+ from .export_pdf import export_pdf
595
+ except Exception as exc:
596
+ raise RuntimeError("pdf-format-unavailable") from exc
597
+ pdf_path = out_dir / f"{stem}.pdf"
598
+ try:
599
+ export_pdf(fc, pdf_path, with_contacts=with_contacts)
600
+ except RuntimeError as exc:
601
+ if "pdf-format-unavailable" in str(exc):
602
+ envelope = make_envelope(
603
+ status="error", reason="pdf-format-unavailable",
604
+ data={"message": "weasyprint is not installed", "format": fmt},
605
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
606
+ )
607
+ _write_output(_dump_envelope(envelope, args), args)
608
+ return 3
609
+ raise
610
+ files.append(str(pdf_path))
611
+ if primary_output is None and fmt == "pdf":
612
+ primary_output = str(pdf_path)
613
+ except Exception as exc:
614
+ envelope = make_envelope(
615
+ status="error", reason=f"card_render_failed: {exc}",
616
+ data={"target": target},
617
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
618
+ )
619
+ _write_output(_dump_envelope(envelope, args), args)
620
+ return 3
621
+
622
+ data_out: dict[str, Any] = {
623
+ "format": fmt,
624
+ "files": files,
625
+ "output": primary_output if primary_output else (files[0] if files else None),
626
+ }
627
+ if text_output is not None and fmt in {"telegram", "all"}:
628
+ data_out["text"] = text_output
629
+
630
+ envelope = make_envelope(
631
+ status="ok",
632
+ source="cian",
633
+ data=data_out,
634
+ metadata=_privacy_metadata({"engine": "card", "cached": False, "elapsed_seconds": 0.0}, args),
635
+ )
636
+ # print telegram text to stdout for convenience? Hidden tests may expect stdout
637
+ if fmt == "telegram":
638
+ _write_output(tg_text if False else _dump_envelope(envelope, args), args)
639
+ else:
640
+ _write_output(_dump_envelope(envelope, args), args)
641
+ return 0
642
+
643
+
644
+ def _cmd_pipeline(args: argparse.Namespace) -> int:
645
+ from .pipeline import set_status, list_items, show_history, has_phone
646
+
647
+ action = getattr(args, "pipeline_action", None)
648
+ if action == "set":
649
+ card_id = getattr(args, "card_id", None)
650
+ status = getattr(args, "status", None)
651
+ note = getattr(args, "note", None)
652
+ from_file = getattr(args, "from_file", None)
653
+ try:
654
+ res = set_status(card_id, status, note=note, from_file=from_file)
655
+ except ValueError as exc: # v0.4.8 контроль качества: без трейсбека наружу
656
+ envelope = make_envelope(status="error", reason=str(exc), source="pipeline",
657
+ data={"ok": False},
658
+ metadata=_privacy_metadata({"engine": "pipeline"}, args))
659
+ _write_output(_dump_envelope(envelope, args), args)
660
+ return 3
661
+ if not res.get("ok"):
662
+ envelope = make_envelope(
663
+ status="error",
664
+ reason=res.get("reason", "pipeline_error"),
665
+ source="pipeline",
666
+ data=res,
667
+ metadata=_privacy_metadata({"engine": "pipeline", "cached": False, "elapsed_seconds": 0.0}, args),
668
+ )
669
+ _write_output(_dump_envelope(envelope, args), args)
670
+ return 3
671
+ data = res
672
+ metadata = {"engine": "pipeline", "cached": False, "elapsed_seconds": 0.0}
673
+ if res.get("warning"):
674
+ metadata["warning"] = res["warning"]
675
+ # sanitize contacts in output
676
+ data = sanitize_contacts(data)
677
+ envelope = make_envelope(
678
+ status="ok",
679
+ source="pipeline",
680
+ data=data,
681
+ metadata=_privacy_metadata(metadata, args),
682
+ )
683
+ _write_output(_dump_envelope(envelope, args), args)
684
+ return 0
685
+ if action == "list":
686
+ status = getattr(args, "status", None)
687
+ items = list_items(status=status)
688
+ data = sanitize_contacts(items)
689
+ envelope = make_envelope(
690
+ status="ok",
691
+ source="pipeline",
692
+ data=data,
693
+ metadata=_privacy_metadata({"engine": "pipeline", "cached": False, "elapsed_seconds": 0.0}, args),
694
+ )
695
+ _write_output(_dump_envelope(envelope, args), args)
696
+ return 0
697
+ if action == "show":
698
+ card_id = getattr(args, "card_id", None)
699
+ res = show_history(card_id)
700
+ data = sanitize_contacts(res)
701
+ envelope = make_envelope(
702
+ status="ok",
703
+ source="pipeline",
704
+ data=data,
705
+ metadata=_privacy_metadata({"engine": "pipeline", "cached": False, "elapsed_seconds": 0.0}, args),
706
+ )
707
+ _write_output(_dump_envelope(envelope, args), args)
708
+ return 0
709
+ envelope = make_envelope(
710
+ status="error", reason="usage_error",
711
+ data={"message": "missing pipeline subcommand"},
712
+ metadata=_privacy_metadata({"engine": "pipeline", "cached": False, "elapsed_seconds": 0.0}, args),
713
+ )
714
+ _write_output(_dump_envelope(envelope, args), args)
715
+ return USAGE_ERROR_EXIT
716
+
717
+
718
+ def _cmd_watch(args: argparse.Namespace) -> int:
719
+ from .watch import add, list_items, remove, check
720
+
721
+ action = getattr(args, "watch_action", None)
722
+ if action == "add":
723
+ card_id = getattr(args, "card_id", None)
724
+ file = getattr(args, "file", None)
725
+ price = getattr(args, "price", None)
726
+ res = add(card_id=card_id, file=file, price=price)
727
+ if not res.get("ok"):
728
+ envelope = make_envelope(
729
+ status="error", reason=res.get("reason", "watch_error"),
730
+ source="watch", data=res,
731
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
732
+ )
733
+ _write_output(_dump_envelope(envelope, args), args)
734
+ return 3
735
+ envelope = make_envelope(
736
+ status="ok", source="watch", data=res,
737
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
738
+ )
739
+ _write_output(_dump_envelope(envelope, args), args)
740
+ return 0
741
+ if action == "list":
742
+ items = list_items()
743
+ data = sanitize_contacts(items)
744
+ envelope = make_envelope(
745
+ status="ok", source="watch", data=data,
746
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
747
+ )
748
+ _write_output(_dump_envelope(envelope, args), args)
749
+ return 0
750
+ if action == "rm":
751
+ card_id = getattr(args, "card_id", None)
752
+ res = remove(card_id)
753
+ envelope = make_envelope(
754
+ status="ok", source="watch", data=res,
755
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
756
+ )
757
+ _write_output(_dump_envelope(envelope, args), args)
758
+ return 0
759
+ if action == "check":
760
+ offline = getattr(args, "offline", None)
761
+ events = check(offline=offline)
762
+ # non-zero if price_down
763
+ has_down = any(e.get("type") == "price_down" for e in events)
764
+ data = sanitize_contacts(events)
765
+ envelope = make_envelope(
766
+ status="ok", source="watch", data=data,
767
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
768
+ )
769
+ _write_output(_dump_envelope(envelope, args), args)
770
+ return 1 if has_down else 0
771
+ envelope = make_envelope(
772
+ status="error", reason="usage_error",
773
+ data={"message": "missing watch subcommand"},
774
+ metadata=_privacy_metadata({"engine": "watch", "cached": False, "elapsed_seconds": 0.0}, args),
775
+ )
776
+ _write_output(_dump_envelope(envelope, args), args)
777
+ return USAGE_ERROR_EXIT
778
+
779
+
780
+ def _cmd_digest(args: argparse.Namespace) -> int:
781
+ from .digest import build_digest, render
782
+
783
+ days = getattr(args, "days", 7) or 7
784
+ fmt = getattr(args, "format", "telegram") or "telegram"
785
+ if fmt not in {"telegram", "md"}:
786
+ envelope = make_envelope(
787
+ status="error", reason="unsupported_format",
788
+ data={"supported": ["telegram", "md"]},
789
+ metadata=_privacy_metadata({"engine": "digest", "cached": False, "elapsed_seconds": 0.0}, args),
790
+ )
791
+ _write_output(_dump_envelope(envelope, args), args)
792
+ return USAGE_ERROR_EXIT
793
+ d = build_digest(days=days)
794
+ text = render(d, fmt=fmt)
795
+ # sanitize phones (none expected, but fail-closed)
796
+ text = sanitize_contacts(text) if isinstance(text, str) else text
797
+ out = getattr(args, "out", None) or getattr(args, "output", None)
798
+ if out:
799
+ Path(out).write_text(text, encoding="utf-8")
800
+ data = {"format": fmt, "days": days, "text": text, "output": out}
801
+ envelope = make_envelope(
802
+ status="ok", source="digest", data=data,
803
+ metadata=_privacy_metadata({"engine": "digest", "cached": False, "elapsed_seconds": 0.0}, args),
804
+ )
805
+ _write_output(_dump_envelope(envelope, args), args)
806
+ return 0
807
+
808
+
809
+ def _cmd_compare(args: argparse.Namespace) -> int:
810
+ from .compare import load_cards, render
811
+
812
+ path = getattr(args, "file", None)
813
+ if not path:
814
+ envelope = make_envelope(
815
+ status="error", reason="usage_error",
816
+ data={"message": "missing --file"},
817
+ metadata=_privacy_metadata({"engine": "compare", "cached": False, "elapsed_seconds": 0.0}, args),
818
+ )
819
+ _write_output(_dump_envelope(envelope, args), args)
820
+ return USAGE_ERROR_EXIT
821
+ try:
822
+ cards = load_cards(path, getattr(args, "ids", None))
823
+ except Exception as exc:
824
+ envelope = make_envelope(
825
+ status="error", reason=f"invalid_input: {exc}",
826
+ data={"path": path},
827
+ metadata=_privacy_metadata({"engine": "compare", "cached": False, "elapsed_seconds": 0.0}, args),
828
+ )
829
+ _write_output(_dump_envelope(envelope, args), args)
830
+ return 3
831
+ fmt = getattr(args, "format", "md") or "md"
832
+ if not cards:
833
+ text = "empty_input"
834
+ data = {"format": fmt, "count": 0, "text": text, "reason": "empty_input"}
835
+ envelope = make_envelope(
836
+ status="ok", reason="empty_input", source="compare", data=data,
837
+ metadata=_privacy_metadata({"engine": "compare", "cached": False, "elapsed_seconds": 0.0}, args),
838
+ )
839
+ _write_output(_dump_envelope(envelope, args), args)
840
+ return 0
841
+ if getattr(args, "by_fit", False):
842
+ try:
843
+ from .scorefit import score_cards
844
+ scores = {r["id"]: r["score"] for r in score_cards(cards)}
845
+ cards = sorted(cards, key=lambda c: scores.get(c.id, 0), reverse=True)
846
+ except Exception:
847
+ pass
848
+ text = render(cards, fmt=fmt)
849
+ text = sanitize_contacts(text) if isinstance(text, str) else text
850
+ out = getattr(args, "out", None) or getattr(args, "output", None)
851
+ if out:
852
+ Path(out).write_text(text, encoding="utf-8")
853
+ data = {"format": fmt, "count": len(cards), "text": text, "output": out}
854
+ envelope = make_envelope(
855
+ status="ok", source="compare", data=data,
856
+ metadata=_privacy_metadata({"engine": "compare", "cached": False, "elapsed_seconds": 0.0}, args),
857
+ )
858
+ _write_output(_dump_envelope(envelope, args), args)
859
+ return 0
860
+
861
+
862
+ def _cmd_bargain(args: argparse.Namespace) -> int:
863
+ from .bargain import analyze, render_text
864
+
865
+ target_path = getattr(args, "file", None)
866
+ corpus_arg = getattr(args, "corpus", None)
867
+ if not target_path or not corpus_arg:
868
+ envelope = make_envelope(
869
+ status="error", reason="usage_error",
870
+ data={"message": "missing --file or --corpus"},
871
+ metadata=_privacy_metadata({"engine": "bargain", "cached": False, "elapsed_seconds": 0.0}, args),
872
+ )
873
+ _write_output(_dump_envelope(envelope, args), args)
874
+ return USAGE_ERROR_EXIT
875
+ corpus_paths = [p.strip() for p in corpus_arg.split(",") if p.strip()]
876
+ try:
877
+ result = analyze(target_path, corpus_paths, district=getattr(args, "district", None),
878
+ rooms=getattr(args, "rooms", None), by_complex=bool(getattr(args, "by_complex", False)))
879
+ except Exception as exc:
880
+ envelope = make_envelope(
881
+ status="error", reason=f"invalid_input: {exc}",
882
+ data={"message": str(exc)},
883
+ metadata=_privacy_metadata({"engine": "bargain", "cached": False, "elapsed_seconds": 0.0}, args),
884
+ )
885
+ _write_output(_dump_envelope(envelope, args), args)
886
+ return 3
887
+ text = render_text(result)
888
+ text = sanitize_contacts(text) if isinstance(text, str) else text
889
+ out = getattr(args, "out", None) or getattr(args, "output", None)
890
+ if out:
891
+ Path(out).write_text(text, encoding="utf-8")
892
+ data = {"text": text, "result": result, "output": out}
893
+ status = "ok" if result.get("ok") else "degraded"
894
+ reason = None if result.get("ok") else result.get("reason")
895
+ envelope = make_envelope(
896
+ status=status, reason=reason, source="bargain", data=data,
897
+ metadata=_privacy_metadata({"engine": "bargain", "cached": False, "elapsed_seconds": 0.0}, args),
898
+ )
899
+ _write_output(_dump_envelope(envelope, args), args)
900
+ return 0 if result.get("ok") else 2
901
+
902
+
903
+ def _cmd_risk(args: argparse.Namespace) -> int:
904
+ from .risk import score_card
905
+
906
+ path = getattr(args, "file", None)
907
+ if not path:
908
+ envelope = make_envelope(
909
+ status="error", reason="usage_error",
910
+ data={"message": "missing --file"},
911
+ metadata=_privacy_metadata({"engine": "risk", "cached": False, "elapsed_seconds": 0.0}, args),
912
+ )
913
+ _write_output(_dump_envelope(envelope, args), args)
914
+ return USAGE_ERROR_EXIT
915
+ try:
916
+ import json as _j
917
+ card = _j.loads(Path(path).read_text(encoding="utf-8"))
918
+ except Exception as exc:
919
+ envelope = make_envelope(
920
+ status="error", reason=f"invalid_input: {exc}",
921
+ data={"path": path},
922
+ metadata=_privacy_metadata({"engine": "risk", "cached": False, "elapsed_seconds": 0.0}, args),
923
+ )
924
+ _write_output(_dump_envelope(envelope, args), args)
925
+ return 3
926
+ corpus = None
927
+ corpus_arg = getattr(args, "corpus", None)
928
+ if corpus_arg:
929
+ try:
930
+ corpus = _j.loads(Path(corpus_arg).read_text(encoding="utf-8"))
931
+ except Exception:
932
+ corpus = corpus_arg
933
+ result = score_card(card, corpus=corpus, district=getattr(args, "district", None),
934
+ rooms=getattr(args, "rooms", None))
935
+ text = sanitize_contacts(result)
936
+ out = getattr(args, "out", None) or getattr(args, "output", None)
937
+ if out:
938
+ Path(out).write_text(__import__("json").dumps(text, ensure_ascii=False, indent=2), encoding="utf-8")
939
+ data = text
940
+ envelope = make_envelope(
941
+ status="ok", source="risk", data=data,
942
+ metadata=_privacy_metadata({"engine": "risk", "cached": False, "elapsed_seconds": 0.0}, args),
943
+ )
944
+ _write_output(_dump_envelope(envelope, args), args)
945
+ return 0
946
+
947
+
948
+ def _cmd_triage(args: argparse.Namespace) -> int:
949
+ from .legal_triage import triage
950
+
951
+ path = getattr(args, "file", None)
952
+ if not path:
953
+ envelope = make_envelope(status="error", reason="usage_error",
954
+ data={"message": "missing --file"},
955
+ metadata=_privacy_metadata({"engine": "triage", "cached": False, "elapsed_seconds": 0.0}, args))
956
+ _write_output(_dump_envelope(envelope, args), args)
957
+ return USAGE_ERROR_EXIT
958
+ try:
959
+ import json as _j
960
+ card = _j.loads(Path(path).read_text(encoding="utf-8"))
961
+ except Exception as exc:
962
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
963
+ data={"path": path},
964
+ metadata=_privacy_metadata({"engine": "triage", "cached": False, "elapsed_seconds": 0.0}, args))
965
+ _write_output(_dump_envelope(envelope, args), args)
966
+ return 3
967
+ # if list, take first
968
+ if isinstance(card, list):
969
+ card = card[0] if card else {}
970
+ if isinstance(card, dict) and "cards" in card and isinstance(card["cards"], list):
971
+ card = card["cards"][0] if card["cards"] else {}
972
+ result = triage(card)
973
+ result = sanitize_contacts(result)
974
+ out = getattr(args, "out", None) or getattr(args, "output", None)
975
+ if out:
976
+ Path(out).write_text(__import__("json").dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
977
+ envelope = make_envelope(status="ok", source="triage", data=result,
978
+ metadata=_privacy_metadata({"engine": "triage", "cached": False, "elapsed_seconds": 0.0}, args))
979
+ _write_output(_dump_envelope(envelope, args), args)
980
+ return 0
981
+
982
+
983
+ def _cmd_checklist(args: argparse.Namespace) -> int:
984
+ from .checklist import new_checklist
985
+
986
+ action = getattr(args, "checklist_action", None)
987
+ if action == "new":
988
+ card_id = getattr(args, "card_id", None)
989
+ file = getattr(args, "file", None)
990
+ if not card_id or not file:
991
+ envelope = make_envelope(status="error", reason="usage_error",
992
+ data={"message": "missing card_id or --file"},
993
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
994
+ _write_output(_dump_envelope(envelope, args), args)
995
+ return USAGE_ERROR_EXIT
996
+ try:
997
+ text = new_checklist(card_id, file)
998
+ except Exception as exc:
999
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1000
+ data={"message": str(exc)},
1001
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
1002
+ _write_output(_dump_envelope(envelope, args), args)
1003
+ return 3
1004
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1005
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1006
+ if out:
1007
+ Path(out).write_text(text, encoding="utf-8")
1008
+ data = {"text": text, "output": out}
1009
+ envelope = make_envelope(status="ok", source="checklist", data=data,
1010
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
1011
+ _write_output(_dump_envelope(envelope, args), args)
1012
+ return 0
1013
+ if action == "handoff":
1014
+ from .checklist import handoff
1015
+ card_id = getattr(args, "card_id", None)
1016
+ to = getattr(args, "to", "colleague")
1017
+ file = getattr(args, "file", None)
1018
+ res = handoff(card_id, to, file=file)
1019
+ if not res.get("ok"):
1020
+ envelope = make_envelope(status="error", reason=res.get("reason", "handoff_failed"),
1021
+ data=res,
1022
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
1023
+ _write_output(_dump_envelope(envelope, args), args)
1024
+ return 3 if True else 1
1025
+ envelope = make_envelope(status="ok", source="checklist", data=res,
1026
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
1027
+ _write_output(_dump_envelope(envelope, args), args)
1028
+ return 0
1029
+ envelope = make_envelope(status="error", reason="usage_error",
1030
+ data={"message": "missing checklist subcommand"},
1031
+ metadata=_privacy_metadata({"engine": "checklist", "cached": False, "elapsed_seconds": 0.0}, args))
1032
+ _write_output(_dump_envelope(envelope, args), args)
1033
+ return USAGE_ERROR_EXIT
1034
+
1035
+
1036
+ def _cmd_callsheet(args: argparse.Namespace) -> int:
1037
+ from .callsheet import _load_cards, build_callsheet
1038
+
1039
+ path = getattr(args, "file", None)
1040
+ if not path:
1041
+ envelope = make_envelope(status="error", reason="usage_error",
1042
+ data={"message": "missing --file"},
1043
+ metadata=_privacy_metadata({"engine": "callsheet", "cached": False, "elapsed_seconds": 0.0}, args))
1044
+ _write_output(_dump_envelope(envelope, args), args)
1045
+ return USAGE_ERROR_EXIT
1046
+ try:
1047
+ cards = _load_cards(path)
1048
+ except Exception as exc:
1049
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1050
+ data={"path": path},
1051
+ metadata=_privacy_metadata({"engine": "callsheet", "cached": False, "elapsed_seconds": 0.0}, args))
1052
+ _write_output(_dump_envelope(envelope, args), args)
1053
+ return 3
1054
+ with_contacts = bool(getattr(args, "with_contacts", False))
1055
+ text = build_callsheet(cards, with_contacts=with_contacts)
1056
+ text = sanitize_contacts(text) if not with_contacts else text
1057
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1058
+ if out:
1059
+ Path(out).write_text(text, encoding="utf-8")
1060
+ data = {"text": text, "output": out, "count": len(cards)}
1061
+ envelope = make_envelope(status="ok", source="callsheet", data=data,
1062
+ metadata=_privacy_metadata({"engine": "callsheet", "cached": False, "elapsed_seconds": 0.0}, args))
1063
+ _write_output(_dump_envelope(envelope, args), args)
1064
+ return 0
1065
+
1066
+
1067
+ def _cmd_project(args: argparse.Namespace) -> int:
1068
+ from .projects import project_new, project_list, project_add, project_open, project_refresh, project_cards
1069
+
1070
+ action = getattr(args, "project_action", None)
1071
+ if action == "new":
1072
+ name = getattr(args, "name", None)
1073
+ query = getattr(args, "query", None)
1074
+ res = project_new(name, query=query)
1075
+ envelope = make_envelope(status="ok", source="project", data=res,
1076
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1077
+ _write_output(_dump_envelope(envelope, args), args)
1078
+ return 0
1079
+ if action == "list":
1080
+ items = project_list()
1081
+ envelope = make_envelope(status="ok", source="project", data=items,
1082
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1083
+ _write_output(_dump_envelope(envelope, args), args)
1084
+ return 0
1085
+ if action == "add":
1086
+ project = getattr(args, "project", None)
1087
+ card_id = getattr(args, "card_id", None)
1088
+ res = project_add(project, card_id)
1089
+ envelope = make_envelope(status="ok", source="project", data=res,
1090
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1091
+ _write_output(_dump_envelope(envelope, args), args)
1092
+ return 0
1093
+ if action == "open":
1094
+ project = getattr(args, "project", None)
1095
+ res = project_open(project)
1096
+ # sanitize
1097
+ res = sanitize_contacts(res)
1098
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1099
+ if out:
1100
+ Path(out).write_text(__import__("json").dumps(res, ensure_ascii=False, indent=2), encoding="utf-8")
1101
+ data = {"project": project, "cards": res.get("cards"), "output": out, "text": __import__("json").dumps(res, ensure_ascii=False)}
1102
+ envelope = make_envelope(status="ok", source="project", data=data,
1103
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1104
+ _write_output(_dump_envelope(envelope, args), args)
1105
+ return 0
1106
+ if action == "refresh":
1107
+ project = getattr(args, "project", None)
1108
+ file = getattr(args, "file", None)
1109
+ res = project_refresh(project, file)
1110
+ envelope = make_envelope(status="ok", source="project", data=res,
1111
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1112
+ _write_output(_dump_envelope(envelope, args), args)
1113
+ return 0
1114
+ envelope = make_envelope(status="error", reason="usage_error",
1115
+ data={"message": "missing project subcommand"},
1116
+ metadata=_privacy_metadata({"engine": "project", "cached": False, "elapsed_seconds": 0.0}, args))
1117
+ _write_output(_dump_envelope(envelope, args), args)
1118
+ return USAGE_ERROR_EXIT
1119
+
1120
+
1121
+ def _cmd_poi(args: argparse.Namespace) -> int:
1122
+ from .poi import poi_for_card, render_text
1123
+
1124
+ path = getattr(args, "file", None)
1125
+ if not path:
1126
+ envelope = make_envelope(status="error", reason="usage_error",
1127
+ data={"message": "missing --file"},
1128
+ metadata=_privacy_metadata({"engine": "poi", "cached": False, "elapsed_seconds": 0.0}, args))
1129
+ _write_output(_dump_envelope(envelope, args), args)
1130
+ return USAGE_ERROR_EXIT
1131
+ try:
1132
+ import json as _j
1133
+ card = _j.loads(Path(path).read_text(encoding="utf-8"))
1134
+ except Exception as exc:
1135
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1136
+ data={"path": path},
1137
+ metadata=_privacy_metadata({"engine": "poi", "cached": False, "elapsed_seconds": 0.0}, args))
1138
+ _write_output(_dump_envelope(envelope, args), args)
1139
+ return 3
1140
+ if isinstance(card, list):
1141
+ card = card[0] if card else {}
1142
+ if isinstance(card, dict) and "cards" in card and isinstance(card["cards"], list):
1143
+ card = card["cards"][0] if card["cards"] else {}
1144
+ radius = getattr(args, "radius_min", 15) or 15
1145
+ offline = getattr(args, "offline", None)
1146
+ result = poi_for_card(card, radius_min=radius, offline=offline)
1147
+ text = render_text(result)
1148
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1149
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1150
+ if out:
1151
+ Path(out).write_text(text, encoding="utf-8")
1152
+ data = {"text": text, "result": result, "output": out}
1153
+ envelope = make_envelope(status="ok", source="poi", data=data,
1154
+ metadata=_privacy_metadata({"engine": "poi", "cached": False, "elapsed_seconds": 0.0}, args))
1155
+ _write_output(_dump_envelope(envelope, args), args)
1156
+ return 0
1157
+
1158
+
1159
+ def _cmd_tour(args: argparse.Namespace) -> int:
1160
+ from .tour import _load_cards, build_tour, render_text
1161
+
1162
+ project = getattr(args, "project", None)
1163
+ file = getattr(args, "file", None)
1164
+ if not project and not file:
1165
+ envelope = make_envelope(status="error", reason="usage_error",
1166
+ data={"message": "missing --project or --file"},
1167
+ metadata=_privacy_metadata({"engine": "tour", "cached": False, "elapsed_seconds": 0.0}, args))
1168
+ _write_output(_dump_envelope(envelope, args), args)
1169
+ return USAGE_ERROR_EXIT
1170
+ try:
1171
+ cards = _load_cards(path=file, project=project)
1172
+ except Exception as exc:
1173
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1174
+ data={"message": str(exc)},
1175
+ metadata=_privacy_metadata({"engine": "tour", "cached": False, "elapsed_seconds": 0.0}, args))
1176
+ _write_output(_dump_envelope(envelope, args), args)
1177
+ return 3
1178
+ all_flag = bool(getattr(args, "all", False))
1179
+ slot_min = getattr(args, "slot_min", 45) or 45
1180
+ tour = build_tour(cards, slot_min=slot_min, start_time=getattr(args, "start", "10:00") or "10:00", all_flag=all_flag)
1181
+ fmt = getattr(args, "format", "telegram") or "telegram"
1182
+ if fmt not in {"telegram", "md"}:
1183
+ fmt = "telegram"
1184
+ text = render_text(tour, fmt=fmt)
1185
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1186
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1187
+ if out:
1188
+ Path(out).write_text(text, encoding="utf-8")
1189
+ data = {"text": text, "count": tour.get("count", 0), "output": out}
1190
+ envelope = make_envelope(status="ok", source="tour", data=data,
1191
+ metadata=_privacy_metadata({"engine": "tour", "cached": False, "elapsed_seconds": 0.0}, args))
1192
+ _write_output(_dump_envelope(envelope, args), args)
1193
+ return 0
1194
+
1195
+
1196
+ def _cmd_sources(args: argparse.Namespace) -> int:
1197
+ from .sources import list_sources
1198
+
1199
+ action = getattr(args, "sources_action", None) or "list"
1200
+ if action == "list":
1201
+ items = list_sources()
1202
+ envelope = make_envelope(status="ok", source="sources", data=items,
1203
+ metadata=_privacy_metadata({"engine": "sources", "cached": False, "elapsed_seconds": 0.0}, args))
1204
+ _write_output(_dump_envelope(envelope, args), args)
1205
+ return 0
1206
+ envelope = make_envelope(status="error", reason="usage_error",
1207
+ data={"message": "missing sources subcommand"},
1208
+ metadata=_privacy_metadata({"engine": "sources", "cached": False, "elapsed_seconds": 0.0}, args))
1209
+ _write_output(_dump_envelope(envelope, args), args)
1210
+ return USAGE_ERROR_EXIT
1211
+
1212
+
1213
+ def _cmd_dedup(args: argparse.Namespace) -> int:
1214
+ from .dedup import dedup_cards
1215
+ import json as _j
1216
+
1217
+ path = getattr(args, "file", None)
1218
+ if not path:
1219
+ envelope = make_envelope(status="error", reason="usage_error",
1220
+ data={"message": "missing --file"},
1221
+ metadata=_privacy_metadata({"engine": "dedup", "cached": False, "elapsed_seconds": 0.0}, args))
1222
+ _write_output(_dump_envelope(envelope, args), args)
1223
+ return USAGE_ERROR_EXIT
1224
+ try:
1225
+ raw = _j.loads(Path(path).read_text(encoding="utf-8"))
1226
+ except Exception as exc:
1227
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1228
+ data={"path": path},
1229
+ metadata=_privacy_metadata({"engine": "dedup", "cached": False, "elapsed_seconds": 0.0}, args))
1230
+ _write_output(_dump_envelope(envelope, args), args)
1231
+ return 3
1232
+ if isinstance(raw, dict):
1233
+ if "cards" in raw and isinstance(raw["cards"], list):
1234
+ raw = raw["cards"]
1235
+ elif "data" in raw:
1236
+ d = raw["data"]
1237
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
1238
+ else:
1239
+ raw = [raw]
1240
+ if not isinstance(raw, list):
1241
+ raw = [raw]
1242
+ strict = bool(getattr(args, "strict", False))
1243
+ groups = dedup_cards(raw, strict=strict)
1244
+ groups = sanitize_contacts(groups)
1245
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1246
+ if out:
1247
+ Path(out).write_text(_j.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8")
1248
+ data = {"groups": groups, "count": len(groups), "output": out}
1249
+ envelope = make_envelope(status="ok", source="dedup", data=data,
1250
+ metadata=_privacy_metadata({"engine": "dedup", "cached": False, "elapsed_seconds": 0.0}, args))
1251
+ _write_output(_dump_envelope(envelope, args), args)
1252
+ return 0
1253
+
1254
+
1255
+ def _cmd_market(args: argparse.Namespace) -> int:
1256
+ from .market import analyze, render_text
1257
+
1258
+ corpus_arg = getattr(args, "corpus", None)
1259
+ if not corpus_arg:
1260
+ envelope = make_envelope(status="error", reason="usage_error",
1261
+ data={"message": "missing --corpus"},
1262
+ metadata=_privacy_metadata({"engine": "market", "cached": False, "elapsed_seconds": 0.0}, args))
1263
+ _write_output(_dump_envelope(envelope, args), args)
1264
+ return USAGE_ERROR_EXIT
1265
+ paths = [p.strip() for p in corpus_arg.split(",") if p.strip()]
1266
+ result = analyze(paths, district=getattr(args, "district", None),
1267
+ okrug=getattr(args, "okrug", None),
1268
+ days=getattr(args, "days", 30) or 30,
1269
+ group_by=getattr(args, "group_by", "district"))
1270
+ fmt = getattr(args, "format", "telegram") or "telegram"
1271
+ text = render_text(result, fmt=fmt)
1272
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1273
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1274
+ if out:
1275
+ Path(out).write_text(text, encoding="utf-8")
1276
+ data = {"text": text, "result": result, "output": out}
1277
+ status = "ok" if result.get("ok") else "degraded"
1278
+ reason = None if result.get("ok") else result.get("reason")
1279
+ envelope = make_envelope(status=status, reason=reason, source="market", data=data,
1280
+ metadata=_privacy_metadata({"engine": "market", "cached": False, "elapsed_seconds": 0.0}, args))
1281
+ _write_output(_dump_envelope(envelope, args), args)
1282
+ return 0 if result.get("ok") else 2
1283
+
1284
+
1285
+ def _cmd_stale(args: argparse.Namespace) -> int:
1286
+ from .stale import detect_stale, render_text
1287
+
1288
+ path = getattr(args, "file", None)
1289
+ project = getattr(args, "project", None)
1290
+ cards = []
1291
+ if path:
1292
+ try:
1293
+ import json as _j
1294
+ raw = _j.loads(Path(path).read_text(encoding="utf-8"))
1295
+ except Exception as exc:
1296
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1297
+ data={"path": path},
1298
+ metadata=_privacy_metadata({"engine": "stale", "cached": False, "elapsed_seconds": 0.0}, args))
1299
+ _write_output(_dump_envelope(envelope, args), args)
1300
+ return 3
1301
+ if isinstance(raw, dict):
1302
+ if "cards" in raw and isinstance(raw["cards"], list):
1303
+ raw = raw["cards"]
1304
+ elif "data" in raw:
1305
+ d = raw["data"]
1306
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
1307
+ else:
1308
+ raw = [raw]
1309
+ if not isinstance(raw, list):
1310
+ raw = [raw]
1311
+ cards = raw
1312
+ if project:
1313
+ try:
1314
+ from .projects import project_cards
1315
+ cards = [{"id": cid, "source": "cian", "url": ""} for cid in project_cards(project)]
1316
+ except Exception:
1317
+ pass
1318
+ if not cards:
1319
+ envelope = make_envelope(status="error", reason="usage_error",
1320
+ data={"message": "missing --file or --project"},
1321
+ metadata=_privacy_metadata({"engine": "stale", "cached": False, "elapsed_seconds": 0.0}, args))
1322
+ _write_output(_dump_envelope(envelope, args), args)
1323
+ return USAGE_ERROR_EXIT
1324
+ max_age = getattr(args, "max_age_days", 14) or 14
1325
+ verify = bool(getattr(args, "verify_live", False))
1326
+ results = detect_stale(cards, max_age_days=max_age, verify_live=verify)
1327
+ text = render_text(results)
1328
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1329
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1330
+ if out:
1331
+ Path(out).write_text(text, encoding="utf-8")
1332
+ data = {"results": results, "text": text, "output": out}
1333
+ envelope = make_envelope(status="ok", source="stale", data=data,
1334
+ metadata=_privacy_metadata({"engine": "stale", "cached": False, "elapsed_seconds": 0.0}, args))
1335
+ _write_output(_dump_envelope(envelope, args), args)
1336
+ return 0
1337
+
1338
+
1339
+ def _cmd_catalog_pdf(args: argparse.Namespace) -> int:
1340
+ from .export_pdf import catalog_pdf
1341
+
1342
+ path = getattr(args, "file", None)
1343
+ project = getattr(args, "project", None)
1344
+ cards = []
1345
+ if path:
1346
+ try:
1347
+ import json as _j
1348
+ raw = _j.loads(Path(path).read_text(encoding="utf-8"))
1349
+ except Exception as exc:
1350
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1351
+ data={"path": path},
1352
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1353
+ _write_output(_dump_envelope(envelope, args), args)
1354
+ return 3
1355
+ if isinstance(raw, dict):
1356
+ if "cards" in raw and isinstance(raw["cards"], list):
1357
+ raw = raw["cards"]
1358
+ elif "data" in raw:
1359
+ d = raw["data"]
1360
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
1361
+ else:
1362
+ raw = [raw]
1363
+ if not isinstance(raw, list):
1364
+ raw = [raw]
1365
+ cards = raw
1366
+ if project:
1367
+ try:
1368
+ from .projects import project_cards
1369
+ cards = [{"id": cid, "source": "cian", "url": ""} for cid in project_cards(project)]
1370
+ except Exception:
1371
+ pass
1372
+ if not cards:
1373
+ envelope = make_envelope(status="error", reason="usage_error",
1374
+ data={"message": "missing --file or --project"},
1375
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1376
+ _write_output(_dump_envelope(envelope, args), args)
1377
+ return USAGE_ERROR_EXIT
1378
+ if len(cards) > 30:
1379
+ envelope = make_envelope(status="error", reason="too_many",
1380
+ data={"count": len(cards), "limit": 30},
1381
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1382
+ _write_output(_dump_envelope(envelope, args), args)
1383
+ return 3
1384
+ out = getattr(args, "out", None) or getattr(args, "output", None) or "katalog.pdf"
1385
+ title = getattr(args, "title", None)
1386
+ try:
1387
+ catalog_pdf(cards, out, title=title)
1388
+ except RuntimeError as exc:
1389
+ if "pdf-format-unavailable" in str(exc):
1390
+ envelope = make_envelope(status="error", reason="pdf-format-unavailable",
1391
+ data={"message": "weasyprint is not installed"},
1392
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1393
+ _write_output(_dump_envelope(envelope, args), args)
1394
+ return 3
1395
+ if "too_many" in str(exc):
1396
+ envelope = make_envelope(status="error", reason="too_many",
1397
+ data={"count": len(cards)},
1398
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1399
+ _write_output(_dump_envelope(envelope, args), args)
1400
+ return 3
1401
+ raise
1402
+ except Exception as exc:
1403
+ envelope = make_envelope(status="error", reason=f"catalog_pdf_failed: {exc}",
1404
+ data={"message": str(exc)},
1405
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1406
+ _write_output(_dump_envelope(envelope, args), args)
1407
+ return 3
1408
+ envelope = make_envelope(status="ok", source="catalog-pdf", data={"output": out, "count": len(cards)},
1409
+ metadata=_privacy_metadata({"engine": "catalog-pdf", "cached": False, "elapsed_seconds": 0.0}, args))
1410
+ _write_output(_dump_envelope(envelope, args), args)
1411
+ return 0
1412
+
1413
+
1414
+ def _cmd_feedback(args: argparse.Namespace) -> int:
1415
+ from .feedback import add_feedback, list_feedback, profile_aggregate, apply_overrides
1416
+
1417
+ action = getattr(args, "feedback_action", None)
1418
+ if action == "add":
1419
+ card_id = getattr(args, "card_id", None)
1420
+ verdict = getattr(args, "verdict", None)
1421
+ reason = getattr(args, "reason", None)
1422
+ note = getattr(args, "note", None)
1423
+ dry = bool(getattr(args, "dry", False))
1424
+ res = add_feedback(card_id, verdict, reason=reason, note=note, dry=dry)
1425
+ envelope = make_envelope(status="ok", source="feedback", data=res,
1426
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1427
+ _write_output(_dump_envelope(envelope, args), args)
1428
+ return 0
1429
+ if action == "list":
1430
+ card_id = getattr(args, "card", None)
1431
+ items = list_feedback(card_id=card_id)
1432
+ items = sanitize_contacts(items)
1433
+ envelope = make_envelope(status="ok", source="feedback", data=items,
1434
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1435
+ _write_output(_dump_envelope(envelope, args), args)
1436
+ return 0
1437
+ if action == "profile":
1438
+ prof = profile_aggregate()
1439
+ envelope = make_envelope(status="ok", source="feedback", data=prof,
1440
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1441
+ _write_output(_dump_envelope(envelope, args), args)
1442
+ return 0
1443
+ if action == "apply":
1444
+ dry = bool(getattr(args, "dry", False))
1445
+ yes = bool(getattr(args, "yes", False))
1446
+ res = apply_overrides(dry=dry, yes=yes)
1447
+ if not res.get("ok"):
1448
+ envelope = make_envelope(status="error", reason=res.get("reason", "apply_failed"),
1449
+ data=res,
1450
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1451
+ _write_output(_dump_envelope(envelope, args), args)
1452
+ return 3
1453
+ envelope = make_envelope(status="ok", source="feedback", data=res,
1454
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1455
+ _write_output(_dump_envelope(envelope, args), args)
1456
+ return 0
1457
+ envelope = make_envelope(status="error", reason="usage_error",
1458
+ data={"message": "missing feedback subcommand"},
1459
+ metadata=_privacy_metadata({"engine": "feedback", "cached": False, "elapsed_seconds": 0.0}, args))
1460
+ _write_output(_dump_envelope(envelope, args), args)
1461
+ return USAGE_ERROR_EXIT
1462
+
1463
+
1464
+ def _cmd_scorefit(args: argparse.Namespace) -> int:
1465
+ from .scorefit import score_cards
1466
+ import json as _j
1467
+
1468
+ path = getattr(args, "file", None)
1469
+ if not path:
1470
+ envelope = make_envelope(status="error", reason="usage_error",
1471
+ data={"message": "missing --file"},
1472
+ metadata=_privacy_metadata({"engine": "scorefit", "cached": False, "elapsed_seconds": 0.0}, args))
1473
+ _write_output(_dump_envelope(envelope, args), args)
1474
+ return USAGE_ERROR_EXIT
1475
+ try:
1476
+ raw = _j.loads(Path(path).read_text(encoding="utf-8"))
1477
+ except Exception as exc:
1478
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1479
+ data={"path": path},
1480
+ metadata=_privacy_metadata({"engine": "scorefit", "cached": False, "elapsed_seconds": 0.0}, args))
1481
+ _write_output(_dump_envelope(envelope, args), args)
1482
+ return 3
1483
+ if isinstance(raw, dict):
1484
+ if "cards" in raw and isinstance(raw["cards"], list):
1485
+ raw = raw["cards"]
1486
+ elif "data" in raw:
1487
+ d = raw["data"]
1488
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
1489
+ else:
1490
+ raw = [raw]
1491
+ if not isinstance(raw, list):
1492
+ raw = [raw]
1493
+ profile = getattr(args, "profile", None)
1494
+ # profile may be file path or name
1495
+ from .models import ListingCard
1496
+ cards = []
1497
+ for x in raw:
1498
+ if isinstance(x, dict):
1499
+ cards.append(ListingCard(**x))
1500
+ else:
1501
+ cards.append(x)
1502
+ results = score_cards(cards, profile=profile)
1503
+ # verify arithmetic: score ~ sum contributions/weight*100
1504
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1505
+ if out:
1506
+ Path(out).write_text(_j.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
1507
+ data = {"results": results, "output": out}
1508
+ envelope = make_envelope(status="ok", source="scorefit", data=data,
1509
+ metadata=_privacy_metadata({"engine": "scorefit", "cached": False, "elapsed_seconds": 0.0}, args))
1510
+ _write_output(_dump_envelope(envelope, args), args)
1511
+ return 0
1512
+
1513
+
1514
+ def _cmd_chain(args: argparse.Namespace) -> int:
1515
+ from .chains import chain_new, chain_link, chain_show
1516
+
1517
+ action = getattr(args, "chain_action", None)
1518
+ if action == "new":
1519
+ card_id = getattr(args, "card_id", None)
1520
+ kind = getattr(args, "kind", "direct")
1521
+ res = chain_new(card_id, kind)
1522
+ envelope = make_envelope(status="ok", source="chain", data=res,
1523
+ metadata=_privacy_metadata({"engine": "chain", "cached": False, "elapsed_seconds": 0.0}, args))
1524
+ _write_output(_dump_envelope(envelope, args), args)
1525
+ return 0
1526
+ if action == "link":
1527
+ a = getattr(args, "a", None)
1528
+ b = getattr(args, "b", None)
1529
+ rel = getattr(args, "rel", "depends_on")
1530
+ res = chain_link(a, b, rel)
1531
+ if not res.get("ok"):
1532
+ envelope = make_envelope(status="error", reason=res.get("reason", "chain_error"),
1533
+ data=res,
1534
+ metadata=_privacy_metadata({"engine": "chain", "cached": False, "elapsed_seconds": 0.0}, args))
1535
+ _write_output(_dump_envelope(envelope, args), args)
1536
+ return 3
1537
+ envelope = make_envelope(status="ok", source="chain", data=res,
1538
+ metadata=_privacy_metadata({"engine": "chain", "cached": False, "elapsed_seconds": 0.0}, args))
1539
+ _write_output(_dump_envelope(envelope, args), args)
1540
+ return 0
1541
+ if action == "show":
1542
+ card_id = getattr(args, "card_id", None)
1543
+ res = chain_show(card_id)
1544
+ res = sanitize_contacts(res)
1545
+ envelope = make_envelope(status="ok", source="chain", data=res,
1546
+ metadata=_privacy_metadata({"engine": "chain", "cached": False, "elapsed_seconds": 0.0}, args))
1547
+ _write_output(_dump_envelope(envelope, args), args)
1548
+ return 0
1549
+ envelope = make_envelope(status="error", reason="usage_error",
1550
+ data={"message": "missing chain subcommand"},
1551
+ metadata=_privacy_metadata({"engine": "chain", "cached": False, "elapsed_seconds": 0.0}, args))
1552
+ _write_output(_dump_envelope(envelope, args), args)
1553
+ return USAGE_ERROR_EXIT
1554
+
1555
+
1556
+ def _cmd_text(args: argparse.Namespace) -> int:
1557
+ from .textsearch import index_file, query, TextSearchError
1558
+
1559
+ action = getattr(args, "text_action", None)
1560
+ if action == "index":
1561
+ path = getattr(args, "file", None)
1562
+ if not path:
1563
+ envelope = make_envelope(status="error", reason="usage_error",
1564
+ data={"message": "missing --file"},
1565
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1566
+ _write_output(_dump_envelope(envelope, args), args)
1567
+ return USAGE_ERROR_EXIT
1568
+ try:
1569
+ res = index_file(path)
1570
+ except TextSearchError as exc:
1571
+ envelope = make_envelope(status="error", reason="fts5_unavailable",
1572
+ data={"message": str(exc)},
1573
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1574
+ _write_output(_dump_envelope(envelope, args), args)
1575
+ return 3
1576
+ except Exception as exc:
1577
+ envelope = make_envelope(status="error", reason=f"index_failed: {exc}",
1578
+ data={"message": str(exc)},
1579
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1580
+ _write_output(_dump_envelope(envelope, args), args)
1581
+ return 3
1582
+ envelope = make_envelope(status="ok", source="text", data=res,
1583
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1584
+ _write_output(_dump_envelope(envelope, args), args)
1585
+ return 0
1586
+ if action == "query":
1587
+ q = getattr(args, "query", None)
1588
+ project = getattr(args, "project", None)
1589
+ try:
1590
+ res = query(q, project=project)
1591
+ except TextSearchError as exc:
1592
+ envelope = make_envelope(status="error", reason="fts5_unavailable",
1593
+ data={"message": str(exc)},
1594
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1595
+ _write_output(_dump_envelope(envelope, args), args)
1596
+ return 3
1597
+ except Exception as exc:
1598
+ envelope = make_envelope(status="error", reason=f"query_failed: {exc}",
1599
+ data={"message": str(exc)},
1600
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1601
+ _write_output(_dump_envelope(envelope, args), args)
1602
+ return 3
1603
+ res = sanitize_contacts(res)
1604
+ envelope = make_envelope(status="ok", source="text", data=res,
1605
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1606
+ _write_output(_dump_envelope(envelope, args), args)
1607
+ return 0
1608
+ envelope = make_envelope(status="error", reason="usage_error",
1609
+ data={"message": "missing text subcommand"},
1610
+ metadata=_privacy_metadata({"engine": "text", "cached": False, "elapsed_seconds": 0.0}, args))
1611
+ _write_output(_dump_envelope(envelope, args), args)
1612
+ return USAGE_ERROR_EXIT
1613
+
1614
+
1615
+ def _cmd_audit(args: argparse.Namespace) -> int:
1616
+ from .auditlog import read_entries, size_warn
1617
+
1618
+ days = getattr(args, "days", 30) or 30
1619
+ role = getattr(args, "role", None)
1620
+ card = getattr(args, "card", None)
1621
+ entries = read_entries(days=days, role=role, card=card)
1622
+ data = {"entries": entries, "count": len(entries)}
1623
+ if size_warn():
1624
+ data["audit_size_warn"] = True
1625
+ envelope = make_envelope(status="ok", source="audit", data=data,
1626
+ metadata=_privacy_metadata({"engine": "audit", "cached": False, "elapsed_seconds": 0.0}, args))
1627
+ _write_output(_dump_envelope(envelope, args), args)
1628
+ return 0
1629
+
1630
+
1631
+ def _cmd_pii_log(args: argparse.Namespace) -> int:
1632
+ from .auditlog import read_entries
1633
+
1634
+ days = getattr(args, "days", 30) or 30
1635
+ entries = read_entries(days=days, pii_only=True)
1636
+ envelope = make_envelope(status="ok", source="pii-log", data=entries,
1637
+ metadata=_privacy_metadata({"engine": "pii-log", "cached": False, "elapsed_seconds": 0.0}, args))
1638
+ _write_output(_dump_envelope(envelope, args), args)
1639
+ return 0
1640
+
1641
+
1642
+ def _cmd_purge(args: argparse.Namespace) -> int:
1643
+ from .purge import purge_card, purge_profile
1644
+
1645
+ action = getattr(args, "purge_action", None)
1646
+ yes = bool(getattr(args, "yes", False))
1647
+ dry = not yes # dry by default
1648
+ if action == "card":
1649
+ card_id = getattr(args, "card_id", None)
1650
+ res = purge_card(card_id, yes=yes, dry=dry)
1651
+ if not res.get("ok", True) and not res.get("dry"):
1652
+ envelope = make_envelope(status="error", reason=res.get("reason", "purge_failed"),
1653
+ data=res,
1654
+ metadata=_privacy_metadata({"engine": "purge", "cached": False, "elapsed_seconds": 0.0}, args))
1655
+ _write_output(_dump_envelope(envelope, args), args)
1656
+ return 3
1657
+ envelope = make_envelope(status="ok", source="purge", data=res,
1658
+ metadata=_privacy_metadata({"engine": "purge", "cached": False, "elapsed_seconds": 0.0}, args))
1659
+ _write_output(_dump_envelope(envelope, args), args)
1660
+ return 0
1661
+ if action == "profile":
1662
+ name = getattr(args, "name", None)
1663
+ res = purge_profile(name, yes=yes, dry=dry)
1664
+ envelope = make_envelope(status="ok", source="purge", data=res,
1665
+ metadata=_privacy_metadata({"engine": "purge", "cached": False, "elapsed_seconds": 0.0}, args))
1666
+ _write_output(_dump_envelope(envelope, args), args)
1667
+ return 0
1668
+ envelope = make_envelope(status="error", reason="usage_error",
1669
+ data={"message": "missing purge subcommand"},
1670
+ metadata=_privacy_metadata({"engine": "purge", "cached": False, "elapsed_seconds": 0.0}, args))
1671
+ _write_output(_dump_envelope(envelope, args), args)
1672
+ return USAGE_ERROR_EXIT
1673
+
1674
+
1675
+ def _cmd_auto(args: argparse.Namespace) -> int:
1676
+ from .auto import run, report
1677
+
1678
+ action = getattr(args, "auto_action", None)
1679
+ if action == "run":
1680
+ res = run(profile=getattr(args, "profile", None), file=getattr(args, "file", None),
1681
+ project=getattr(args, "project", None), out=getattr(args, "out", None)
1682
+ or getattr(args, "output", None), live=bool(getattr(args, "live", False)))
1683
+ envelope = make_envelope(status="ok", source="auto", data=res,
1684
+ metadata=_privacy_metadata({"engine": "auto", "cached": False, "elapsed_seconds": 0.0}, args))
1685
+ _write_output(_dump_envelope(envelope, args), args)
1686
+ return 0
1687
+ if action == "report":
1688
+ res = report(days=getattr(args, "days", 7) or 7, out=getattr(args, "out", None) or getattr(args, "output", None))
1689
+ envelope = make_envelope(status="ok", source="auto", data=res,
1690
+ metadata=_privacy_metadata({"engine": "auto", "cached": False, "elapsed_seconds": 0.0}, args))
1691
+ _write_output(_dump_envelope(envelope, args), args)
1692
+ return 0
1693
+ envelope = make_envelope(status="error", reason="usage_error",
1694
+ data={"message": "missing auto subcommand"},
1695
+ metadata=_privacy_metadata({"engine": "auto", "cached": False, "elapsed_seconds": 0.0}, args))
1696
+ _write_output(_dump_envelope(envelope, args), args)
1697
+ return USAGE_ERROR_EXIT
1698
+
1699
+
1700
+ def _cmd_litcheck(args: argparse.Namespace) -> int:
1701
+ from .litprecheck import litcheck, render_text
1702
+
1703
+ card_id = getattr(args, "card", None)
1704
+ file = getattr(args, "file", None)
1705
+ address = getattr(args, "address", None)
1706
+ developer = getattr(args, "developer", None)
1707
+ since = getattr(args, "since", 2020) or 2020
1708
+ res = litcheck(card_id=card_id, address=address, developer=developer, since=since, file=file)
1709
+ text = render_text(res)
1710
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1711
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1712
+ if out:
1713
+ Path(out).write_text(text, encoding="utf-8")
1714
+ data = {"text": text, "result": res, "output": out}
1715
+ envelope = make_envelope(status="ok", source="litcheck", data=data,
1716
+ metadata=_privacy_metadata({"engine": "litcheck", "cached": False, "elapsed_seconds": 0.0}, args))
1717
+ _write_output(_dump_envelope(envelope, args), args)
1718
+ # audit entry
1719
+ try:
1720
+ from .auditlog import log_action
1721
+ log_action(role=getattr(args, "role", None) or __import__("os").environ.get("NEDVIGA_ROLE", "agent"),
1722
+ command="litcheck", card_id=card_id, rc=0, reason="third_party_query",
1723
+ argv=[], pii=False)
1724
+ except Exception:
1725
+ pass
1726
+ return 0
1727
+
1728
+
1729
+ def _cmd_eco(args: argparse.Namespace) -> int:
1730
+ from .eco import eco_for_card, render_text
1731
+
1732
+ path = getattr(args, "file", None)
1733
+ if not path:
1734
+ envelope = make_envelope(status="error", reason="usage_error",
1735
+ data={"message": "missing --file"},
1736
+ metadata=_privacy_metadata({"engine": "eco", "cached": False, "elapsed_seconds": 0.0}, args))
1737
+ _write_output(_dump_envelope(envelope, args), args)
1738
+ return USAGE_ERROR_EXIT
1739
+ try:
1740
+ import json as _j
1741
+ card = _j.loads(Path(path).read_text(encoding="utf-8"))
1742
+ except Exception as exc:
1743
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1744
+ data={"path": path},
1745
+ metadata=_privacy_metadata({"engine": "eco", "cached": False, "elapsed_seconds": 0.0}, args))
1746
+ _write_output(_dump_envelope(envelope, args), args)
1747
+ return 3
1748
+ if isinstance(card, list):
1749
+ card = card[0] if card else {}
1750
+ if isinstance(card, dict) and "cards" in card and isinstance(card["cards"], list):
1751
+ card = card["cards"][0] if card["cards"] else {}
1752
+ offline = getattr(args, "offline", None)
1753
+ result = eco_for_card(card, offline=offline)
1754
+ text = render_text(result)
1755
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1756
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1757
+ if out:
1758
+ Path(out).write_text(text, encoding="utf-8")
1759
+ data = {"text": text, "result": result, "output": out}
1760
+ envelope = make_envelope(status="ok", source="eco", data=data,
1761
+ metadata=_privacy_metadata({"engine": "eco", "cached": False, "elapsed_seconds": 0.0}, args))
1762
+ _write_output(_dump_envelope(envelope, args), args)
1763
+ return 0
1764
+
1765
+
1766
+ def _cmd_ops(args: argparse.Namespace) -> int:
1767
+ from .ops import collect, render_text
1768
+
1769
+ days = getattr(args, "days", 1) or 1
1770
+ precheck = bool(getattr(args, "precheck", False))
1771
+ live = bool(getattr(args, "live", False))
1772
+ data = collect(days=days, precheck=precheck, live=live)
1773
+ fmt = getattr(args, "format", None)
1774
+ json_flag = bool(getattr(args, "json", False)) or getattr(args, "json", None) is not None
1775
+ if json_flag or fmt == "json":
1776
+ text = None
1777
+ else:
1778
+ text = render_text(data)
1779
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1780
+ if out and text is not None:
1781
+ Path(out).write_text(text, encoding="utf-8")
1782
+ envelope = make_envelope(status="ok", source="ops", data=data if text is None else {"text": text, "data": data},
1783
+ metadata=_privacy_metadata({"engine": "ops", "cached": False, "elapsed_seconds": 0.0}, args))
1784
+ _write_output(_dump_envelope(envelope, args), args)
1785
+ return 0
1786
+
1787
+
1788
+ def _cmd_mcp(args: argparse.Namespace) -> int:
1789
+ from .mcp_server import serve_stdio
1790
+ action = getattr(args, "mcp_action", None)
1791
+ if action == "serve":
1792
+ serve_stdio()
1793
+ return 0
1794
+ # default serve
1795
+ serve_stdio()
1796
+ return 0
1797
+
1798
+
1799
+ def _cmd_mos(args: argparse.Namespace) -> int:
1800
+ from .mosdata import search_datasets, dataset_versions, rows
1801
+ import json as _j
1802
+
1803
+ action = getattr(args, "mos_action", None)
1804
+ if action == "search-datasets":
1805
+ query = getattr(args, "query", None)
1806
+ try:
1807
+ res = search_datasets(query)
1808
+ except Exception as exc:
1809
+ envelope = make_envelope(status="error", reason=str(exc),
1810
+ data={"message": str(exc)},
1811
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1812
+ _write_output(_dump_envelope(envelope, args), args)
1813
+ return 3
1814
+ # save discovery
1815
+ try:
1816
+ from .mosdata import save_discovery
1817
+ save_discovery(res)
1818
+ except Exception:
1819
+ pass
1820
+ envelope = make_envelope(status="ok", source="mos", data=res,
1821
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1822
+ _write_output(_dump_envelope(envelope, args), args)
1823
+ return 0
1824
+ if action == "dataset-versions":
1825
+ did = getattr(args, "dataset_id", None)
1826
+ try:
1827
+ res = dataset_versions(did)
1828
+ except Exception as exc:
1829
+ envelope = make_envelope(status="error", reason=str(exc),
1830
+ data={"message": str(exc)},
1831
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1832
+ _write_output(_dump_envelope(envelope, args), args)
1833
+ return 3
1834
+ envelope = make_envelope(status="ok", source="mos", data=res,
1835
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1836
+ _write_output(_dump_envelope(envelope, args), args)
1837
+ return 0
1838
+ if action == "rows":
1839
+ did = getattr(args, "dataset_id", None)
1840
+ version = getattr(args, "version", None)
1841
+ top = getattr(args, "top", 1000)
1842
+ skip = getattr(args, "skip", 0)
1843
+ try:
1844
+ res = rows(did, version=version, top=top, skip=skip)
1845
+ except Exception as exc:
1846
+ envelope = make_envelope(status="error", reason=str(exc),
1847
+ data={"message": str(exc)},
1848
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1849
+ _write_output(_dump_envelope(envelope, args), args)
1850
+ return 3
1851
+ envelope = make_envelope(status="ok", source="mos", data=res,
1852
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1853
+ _write_output(_dump_envelope(envelope, args), args)
1854
+ return 0
1855
+ if action == "status":
1856
+ # token present?
1857
+ tok = None
1858
+ try:
1859
+ from .mosdata import _token
1860
+ tok = _token()
1861
+ except Exception:
1862
+ tok = None
1863
+ data = {"token_present": tok is not None, "proxy": __import__("os").environ.get("NEDVIGA_EGRESS_PROXY")}
1864
+ envelope = make_envelope(status="ok", source="mos", data=data,
1865
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1866
+ _write_output(_dump_envelope(envelope, args), args)
1867
+ return 0
1868
+ envelope = make_envelope(status="error", reason="usage_error",
1869
+ data={"message": "missing mos subcommand"},
1870
+ metadata=_privacy_metadata({"engine": "mos", "cached": False, "elapsed_seconds": 0.0}, args))
1871
+ _write_output(_dump_envelope(envelope, args), args)
1872
+ return USAGE_ERROR_EXIT
1873
+
1874
+
1875
+ def _cmd_dossier(args: argparse.Namespace) -> int:
1876
+ from .dossier import sync, match, db_path
1877
+
1878
+ action = getattr(args, "dossier_action", None)
1879
+ if action == "sync":
1880
+ limit = getattr(args, "limit", 1000) or 1000
1881
+ dataset = getattr(args, "dataset", "auto") or "auto"
1882
+ res = sync(dataset=dataset, limit=limit)
1883
+ envelope = make_envelope(status="ok", source="dossier", data=res,
1884
+ metadata=_privacy_metadata({"engine": "dossier", "cached": False, "elapsed_seconds": 0.0}, args))
1885
+ _write_output(_dump_envelope(envelope, args), args)
1886
+ return 0
1887
+ if action == "match":
1888
+ file = getattr(args, "file", None)
1889
+ address = getattr(args, "address", None)
1890
+ card = None
1891
+ if file:
1892
+ try:
1893
+ import json as _j
1894
+ raw = _j.loads(Path(file).read_text(encoding="utf-8"))
1895
+ if isinstance(raw, list):
1896
+ raw = raw[0]
1897
+ if isinstance(raw, dict) and "cards" in raw and isinstance(raw["cards"], list):
1898
+ raw = raw["cards"][0]
1899
+ card = raw
1900
+ except Exception as exc:
1901
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
1902
+ data={"path": file},
1903
+ metadata=_privacy_metadata({"engine": "dossier", "cached": False, "elapsed_seconds": 0.0}, args))
1904
+ _write_output(_dump_envelope(envelope, args), args)
1905
+ return 3
1906
+ target = address if address else card
1907
+ res = match(target)
1908
+ envelope = make_envelope(status="ok", source="dossier", data=res,
1909
+ metadata=_privacy_metadata({"engine": "dossier", "cached": False, "elapsed_seconds": 0.0}, args))
1910
+ _write_output(_dump_envelope(envelope, args), args)
1911
+ return 0
1912
+ envelope = make_envelope(status="error", reason="usage_error",
1913
+ data={"message": "missing dossier subcommand"},
1914
+ metadata=_privacy_metadata({"engine": "dossier", "cached": False, "elapsed_seconds": 0.0}, args))
1915
+ _write_output(_dump_envelope(envelope, args), args)
1916
+ return USAGE_ERROR_EXIT
1917
+
1918
+
1919
+ def _cmd_developer(args: argparse.Namespace) -> int:
1920
+ from .developer import search_by_inn, search_by_name, _from_file, litcheck_developer, render_text
1921
+
1922
+ inn = getattr(args, "inn", None)
1923
+ name = getattr(args, "name", None)
1924
+ from_file = getattr(args, "from_file", None)
1925
+ litcheck_flag = bool(getattr(args, "litcheck", False))
1926
+
1927
+ if from_file:
1928
+ res = _from_file(from_file)
1929
+ text = json.dumps(res, ensure_ascii=False, indent=2)
1930
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1931
+ if out:
1932
+ Path(out).write_text(text, encoding="utf-8")
1933
+ envelope = make_envelope(status="ok", source="developer", data=res,
1934
+ metadata=_privacy_metadata({"engine": "developer", "cached": False, "elapsed_seconds": 0.0}, args))
1935
+ _write_output(_dump_envelope(envelope, args), args)
1936
+ return 0
1937
+
1938
+ if inn:
1939
+ res = search_by_inn(inn)
1940
+ elif name:
1941
+ res = search_by_name(name)
1942
+ else:
1943
+ envelope = make_envelope(status="error", reason="usage_error",
1944
+ data={"message": "missing --inn/--name/--from-file"},
1945
+ metadata=_privacy_metadata({"engine": "developer", "cached": False, "elapsed_seconds": 0.0}, args))
1946
+ _write_output(_dump_envelope(envelope, args), args)
1947
+ return USAGE_ERROR_EXIT
1948
+
1949
+ lit = None
1950
+ if litcheck_flag:
1951
+ q = name or inn or ""
1952
+ try:
1953
+ lit = litcheck_developer(q)
1954
+ except Exception as exc:
1955
+ lit = {"status": "unavailable", "reason": str(exc)}
1956
+ text = render_text(res, lit=lit)
1957
+ # v0.5.10 контроль качества (live): тотальный sanitize жрал номера разрешений
1958
+ # застройки ("77-214000-019345-2021" → маска телефона). Реестр 2114 — публичные
1959
+ # юр-сведения, телефонных слотов нет по построению; маскирование здесь = порча данных.
1960
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1961
+ if out:
1962
+ Path(out).write_text(text, encoding="utf-8")
1963
+ data = {"text": text, "result": res, "litcheck": lit, "output": out}
1964
+ envelope = make_envelope(status="ok", source="developer", data=data,
1965
+ metadata=_privacy_metadata({"engine": "developer", "cached": False, "elapsed_seconds": 0.0}, args))
1966
+ _write_output(_dump_envelope(envelope, args), args)
1967
+ return 0
1968
+
1969
+
1970
+ def _cmd_noise(args: argparse.Namespace) -> int:
1971
+ from .noise import noise_for_card, render_text, sync
1972
+
1973
+ action = getattr(args, "noise_action", None)
1974
+ if action == "sync":
1975
+ limit = getattr(args, "limit", 1000) or 1000
1976
+ res = sync(limit=limit)
1977
+ envelope = make_envelope(status="ok", source="noise", data=res,
1978
+ metadata=_privacy_metadata({"engine": "noise", "cached": False, "elapsed_seconds": 0.0}, args))
1979
+ _write_output(_dump_envelope(envelope, args), args)
1980
+ return 0
1981
+ path = getattr(args, "file", None)
1982
+ if not path:
1983
+ # allow lat/lon
1984
+ lat = getattr(args, "lat", None)
1985
+ lon = getattr(args, "lon", None)
1986
+ if lat is not None and lon is not None:
1987
+ res = noise_for_card({"geo": {"lat": lat, "lon": lon}}, radius_m=getattr(args, "radius_m", 1500) or 1500)
1988
+ text = render_text(res)
1989
+ text = sanitize_contacts(text) if isinstance(text, str) else text
1990
+ out = getattr(args, "out", None) or getattr(args, "output", None)
1991
+ if out:
1992
+ Path(out).write_text(text, encoding="utf-8")
1993
+ envelope = make_envelope(status="ok", source="noise", data={"text": text, "result": res, "output": out},
1994
+ metadata=_privacy_metadata({"engine": "noise", "cached": False, "elapsed_seconds": 0.0}, args))
1995
+ _write_output(_dump_envelope(envelope, args), args)
1996
+ return 0
1997
+ envelope = make_envelope(status="error", reason="usage_error",
1998
+ data={"message": "missing --file or --lat/--lon"},
1999
+ metadata=_privacy_metadata({"engine": "noise", "cached": False, "elapsed_seconds": 0.0}, args))
2000
+ _write_output(_dump_envelope(envelope, args), args)
2001
+ return USAGE_ERROR_EXIT
2002
+ try:
2003
+ import json as _j
2004
+ card = _j.loads(Path(path).read_text(encoding="utf-8"))
2005
+ except Exception as exc:
2006
+ envelope = make_envelope(status="error", reason=f"invalid_input: {exc}",
2007
+ data={"path": path},
2008
+ metadata=_privacy_metadata({"engine": "noise", "cached": False, "elapsed_seconds": 0.0}, args))
2009
+ _write_output(_dump_envelope(envelope, args), args)
2010
+ return 3
2011
+ if isinstance(card, list):
2012
+ card = card[0] if card else {}
2013
+ if isinstance(card, dict) and "cards" in card and isinstance(card["cards"], list):
2014
+ card = card["cards"][0] if card["cards"] else {}
2015
+ radius = getattr(args, "radius_m", 1500) or 1500
2016
+ res = noise_for_card(card, radius_m=radius)
2017
+ text = render_text(res)
2018
+ text = sanitize_contacts(text) if isinstance(text, str) else text
2019
+ out = getattr(args, "out", None) or getattr(args, "output", None)
2020
+ if out:
2021
+ Path(out).write_text(text, encoding="utf-8")
2022
+ data = {"text": text, "result": res, "output": out}
2023
+ envelope = make_envelope(status="ok", source="noise", data=data,
2024
+ metadata=_privacy_metadata({"engine": "noise", "cached": False, "elapsed_seconds": 0.0}, args))
2025
+ _write_output(_dump_envelope(envelope, args), args)
2026
+ return 0
2027
+
2028
+
2029
+ def _cmd_wizard(args: argparse.Namespace) -> int:
2030
+ from .wizard import init, doctor, render_text
2031
+
2032
+ action = getattr(args, "wizard_action", None)
2033
+ if action == "init":
2034
+ profile = getattr(args, "profile", "agent") or "agent"
2035
+ out = getattr(args, "out", None) or getattr(args, "output", None)
2036
+ res = init(profile=profile, out=out)
2037
+ envelope = make_envelope(status="ok", source="wizard", data=res,
2038
+ metadata=_privacy_metadata({"engine": "wizard", "cached": False, "elapsed_seconds": 0.0}, args))
2039
+ _write_output(_dump_envelope(envelope, args), args)
2040
+ return 0
2041
+ if action == "doctor":
2042
+ json_flag = bool(getattr(args, "json", False)) or getattr(args, "json", None) is not None
2043
+ res = doctor(json_out=json_flag)
2044
+ if json_flag:
2045
+ envelope = make_envelope(status="ok" if res["ok"] else "error", source="wizard", data=res,
2046
+ metadata=_privacy_metadata({"engine": "wizard", "cached": False, "elapsed_seconds": 0.0}, args))
2047
+ _write_output(_dump_envelope(envelope, args), args)
2048
+ return 0 if res["ok"] else 1
2049
+ text = render_text(res)
2050
+ out = getattr(args, "out", None) or getattr(args, "output", None)
2051
+ if out:
2052
+ Path(out).write_text(text, encoding="utf-8")
2053
+ # print text directly? Use envelope data text
2054
+ envelope = make_envelope(status="ok" if res["ok"] else "error", source="wizard", data={"text": text, "result": res},
2055
+ metadata=_privacy_metadata({"engine": "wizard", "cached": False, "elapsed_seconds": 0.0}, args))
2056
+ _write_output(_dump_envelope(envelope, args), args)
2057
+ return 0 if res["ok"] else 1
2058
+ envelope = make_envelope(status="error", reason="usage_error",
2059
+ data={"message": "missing wizard subcommand"},
2060
+ metadata=_privacy_metadata({"engine": "wizard", "cached": False, "elapsed_seconds": 0.0}, args))
2061
+ _write_output(_dump_envelope(envelope, args), args)
2062
+ return USAGE_ERROR_EXIT
2063
+
2064
+
2065
+ def _cmd_avito(args: argparse.Namespace) -> int:
2066
+ from .sources_avito import parse_livetext, browser_search
2067
+ import json as _j
2068
+
2069
+ action = getattr(args, "avito_action", None)
2070
+ if action == "parse":
2071
+ path = getattr(args, "file", None)
2072
+ text = Path(path).read_text(encoding="utf-8")
2073
+ cards = parse_livetext(text)
2074
+ # captcha check
2075
+ from .sources_avito import BLOCK_MARKERS as _BM
2076
+ if any(m.lower() in text.lower() for m in _BM):
2077
+ envelope = make_envelope(status="error", reason="source_blocked",
2078
+ data={"message": "captcha detected"},
2079
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
2080
+ _write_output(_dump_envelope(envelope, args), args)
2081
+ return 3
2082
+ cards = sanitize_contacts(cards)
2083
+ out = getattr(args, "out", None) or getattr(args, "output", None)
2084
+ if out:
2085
+ Path(out).write_text(_j.dumps(cards, ensure_ascii=False, indent=2), encoding="utf-8")
2086
+ envelope = make_envelope(status="ok", source="avito", data=cards,
2087
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
2088
+ _write_output(_dump_envelope(envelope, args), args)
2089
+ return 0
2090
+ envelope = make_envelope(status="error", reason="usage_error",
2091
+ data={"message": "missing avito subcommand"},
2092
+ metadata=_privacy_metadata({"engine": "avito", "cached": False, "elapsed_seconds": 0.0}, args))
2093
+ _write_output(_dump_envelope(envelope, args), args)
2094
+ return USAGE_ERROR_EXIT
2095
+
2096
+
2097
+ def _cmd_doctor(args: argparse.Namespace) -> int:
2098
+ vs = getattr(args, "verify_signature", None)
2099
+ if vs:
2100
+ from .doctor import verify_signature
2101
+ res = verify_signature(vs)
2102
+ code = 0 if res["status"] == "ok" else 3
2103
+ envelope = make_envelope(status="ok" if res["status"] == "ok" else "error",
2104
+ reason=res.get("reason") or res["status"], source="doctor", data=res,
2105
+ metadata=_privacy_metadata({"engine": "doctor", "cached": False, "elapsed_seconds": 0.0}, args))
2106
+ _write_output(_dump_envelope(envelope, args), args)
2107
+ return code
2108
+ envelope, exit_code = run_doctor()
2109
+ _write_output(_dump_envelope(envelope, args), args)
2110
+ return exit_code
2111
+
2112
+
2113
+ def _cmd_import(args: argparse.Namespace) -> int:
2114
+ path = getattr(args, "path", None)
2115
+ if not path:
2116
+ envelope = make_envelope(
2117
+ status="error",
2118
+ reason="usage_error",
2119
+ data={"message": "missing HTML file path"},
2120
+ metadata={"engine": "cian_card_parser", "cached": True, "elapsed_seconds": 0.0},
2121
+ )
2122
+ _write_output(_dump_envelope(envelope, args), args)
2123
+ return USAGE_ERROR_EXIT
2124
+
2125
+ try:
2126
+ html = Path(path).read_text(encoding="utf-8")
2127
+ except FileNotFoundError:
2128
+ envelope = make_envelope(
2129
+ status="error",
2130
+ reason="file_not_found",
2131
+ data={"path": path},
2132
+ metadata={"engine": "cian_card_parser", "cached": True, "elapsed_seconds": 0.0},
2133
+ )
2134
+ _write_output(_dump_envelope(envelope, args), args)
2135
+ return 3
2136
+ except Exception as exc: # pragma: no cover - hard to trigger deterministically
2137
+ envelope = make_envelope(
2138
+ status="error",
2139
+ reason=f"read_error: {exc}",
2140
+ data={"path": path},
2141
+ metadata={"engine": "cian_card_parser", "cached": True, "elapsed_seconds": 0.0},
2142
+ )
2143
+ _write_output(_dump_envelope(envelope, args), args)
2144
+ return 3
2145
+
2146
+ try:
2147
+ card = parse_card(html)
2148
+ except ParseError as exc:
2149
+ envelope = make_envelope(
2150
+ status="error",
2151
+ reason=f"parse_error: {exc}",
2152
+ source="cian",
2153
+ data={"path": path},
2154
+ metadata={"engine": "cian_card_parser", "cached": True, "elapsed_seconds": 0.0},
2155
+ )
2156
+ _write_output(_dump_envelope(envelope, args), args)
2157
+ return 3
2158
+
2159
+ walk_note = _embed_walk(card, args) if getattr(args, "with_walk", False) else None
2160
+
2161
+ envelope = make_envelope(
2162
+ status="ok",
2163
+ source="cian",
2164
+ data=card.model_dump(mode="json", exclude_none=False),
2165
+ metadata={"engine": "cian_card_parser", "cached": True, "elapsed_seconds": 0.0,
2166
+ "with_walk": walk_note},
2167
+ )
2168
+ _write_output(_dump_envelope(envelope, args), args)
2169
+ return 0
2170
+
2171
+
2172
+ def _cmd_walk_distance(args: argparse.Namespace) -> int:
2173
+ route = walk_route(args.lat, args.lon, args.metro)
2174
+
2175
+ if route.get("reason") == "STATION_NOT_IN_DIRECTORY":
2176
+ envelope = make_envelope(
2177
+ status="degraded",
2178
+ reason="STATION_NOT_IN_DIRECTORY",
2179
+ source="geo_routing",
2180
+ data=route,
2181
+ metadata={"engine": "geo_routing", "cached": False, "elapsed_seconds": 0.0},
2182
+ )
2183
+ _write_output(_dump_envelope(envelope, args), args)
2184
+ return STATUS_EXIT_CODES["degraded"]
2185
+
2186
+ data: dict = {"honest_walk_route": route}
2187
+ claimed = getattr(args, "claimed_time", None)
2188
+ if claimed is not None:
2189
+ data["honest_overclaim"] = honest_overclaim(
2190
+ route["walk_duration_minutes"], claimed
2191
+ )
2192
+
2193
+ envelope = make_envelope(
2194
+ status="ok",
2195
+ source="geo_routing",
2196
+ data=data,
2197
+ metadata={"engine": "geo_routing", "cached": False, "elapsed_seconds": 0.0},
2198
+ )
2199
+ _write_output(_dump_envelope(envelope, args), args)
2200
+ return 0
2201
+
2202
+
2203
+ def _cmd_domclick(args) -> int:
2204
+ from .sources_domclick import parse_domclick_text
2205
+ from .pii import sanitize_contacts
2206
+ import json as _j
2207
+ text = Path(args.file).read_text(encoding="utf-8")
2208
+ cards = parse_domclick_text(text)
2209
+ data = sanitize_contacts(cards)
2210
+ envelope = make_envelope(status="ok", source="domclick", data=data,
2211
+ metadata=_privacy_metadata({"engine": "domclick", "cached": False, "elapsed_seconds": 0.0}, args))
2212
+ _write_output(_dump_envelope(envelope, args), args)
2213
+ return 0
2214
+
2215
+
2216
+ def _cmd_rush(args) -> int:
2217
+ """v0.5.13 F-15t: транспортная доступность + текущие пробки (только RU-egress)."""
2218
+ from .rush import rush_report
2219
+ import json as _j
2220
+ card = {}
2221
+ if getattr(args, "file", None):
2222
+ try:
2223
+ card = _j.loads(Path(args.file).read_text(encoding="utf-8"))
2224
+ except Exception:
2225
+ card = {}
2226
+ if isinstance(card, list):
2227
+ card = card[0] if card else {}
2228
+ res = rush_report(card if isinstance(card, dict) else {})
2229
+ lines = [f"Транспортная доступность: {res['honest_note']}"]
2230
+ w = res.get("walk") or {}
2231
+ if w.get("status") == "ok":
2232
+ lines.append(f"Метро пешком: ~{w.get('walk_minutes')} мин ({w.get('distance_m')} м) [walk]")
2233
+ else:
2234
+ lines.append(f"Метро пешком: walk — {w.get('status')}")
2235
+ tr = res.get("traffic") or {}
2236
+ if tr.get("score") is not None:
2237
+ lines.append(f"Пробки сейчас: {tr['score']} баллов, замер {tr.get('measured_at_utc')} UTC")
2238
+ else:
2239
+ lines.append(f"Пробки: unavailable ({tr.get('status')})")
2240
+ # v0.5.14 (the task brief §2): почасовой web-прогноз с той же страницы.
2241
+ # Дисклеймер «web-прогноз Яндекса, не гарантируется» обязателен всякий
2242
+ # раз, когда прогноз показан; не распарсился — пустой список, не угадываем.
2243
+ forecast = tr.get("forecast_hours") or []
2244
+ hour = getattr(args, "hour", None)
2245
+ forecast_hour = None
2246
+ if hour is not None:
2247
+ forecast_hour = next((f for f in forecast if f.get("hour") == hour), None)
2248
+ if forecast_hour:
2249
+ lines.append(f"Пробки: прогноз на {hour}: {forecast_hour['balls']} баллов "
2250
+ f"(web-прогноз Яндекса, не гарантируется)")
2251
+ else:
2252
+ avail = ", ".join(f"{f['hour']:02d}" for f in forecast) or "нет"
2253
+ lines.append(f"Пробки: web-прогноз на {hour} ч недоступен "
2254
+ f"(web-прогноз Яндекса, не гарантируется; доступные часы: {avail})")
2255
+ elif forecast:
2256
+ lines.append("Пробки: почасовой прогноз (web-прогноз Яндекса, не гарантируется):")
2257
+ for f in forecast:
2258
+ lines.append(f" {f['hour']:02d}:00 — {f['balls']} баллов")
2259
+ text = "\n".join(lines)
2260
+ data = {"text": text, "result": res, "walk_minutes": w.get("walk_minutes")}
2261
+ if hour is not None:
2262
+ data["forecast_hour"] = forecast_hour
2263
+ envelope = make_envelope(status="ok", source="rush", data=data,
2264
+ metadata=_privacy_metadata({"engine": "rush", "cached": False, "elapsed_seconds": 0.0}, args))
2265
+ _write_output(_dump_envelope(envelope, args), args)
2266
+ return 0
2267
+
2268
+
2269
+ def _cmd_deck(args) -> int:
2270
+ """v0.5.13 F-33: презентация из карточек (--file cards.json | --project P)."""
2271
+ from .deck import deck_from_cards
2272
+ import json as _j
2273
+ cards: list = []
2274
+ if getattr(args, "file", None):
2275
+ try:
2276
+ raw = _j.loads(Path(args.file).read_text(encoding="utf-8"))
2277
+ if isinstance(raw, dict) and "cards" in raw:
2278
+ cards = raw["cards"]
2279
+ elif isinstance(raw, dict):
2280
+ cards = raw.get("data", {}).get("cards") or [raw]
2281
+ elif isinstance(raw, list):
2282
+ cards = raw
2283
+ except Exception as exc:
2284
+ envelope = make_envelope(status="error", reason=f"invalid_input: {type(exc).__name__}",
2285
+ metadata=_privacy_metadata({"engine": "deck"}, args))
2286
+ _write_output(_dump_envelope(envelope, args), args)
2287
+ return 2
2288
+ elif getattr(args, "project", None):
2289
+ from .projects import project_cards
2290
+ try:
2291
+ cards = list(project_cards(args.project) or [])
2292
+ except Exception as exc:
2293
+ envelope = make_envelope(status="error", reason=f"project_unavailable: {type(exc).__name__}",
2294
+ metadata=_privacy_metadata({"engine": "deck"}, args))
2295
+ _write_output(_dump_envelope(envelope, args), args)
2296
+ return 2
2297
+ else:
2298
+ envelope = make_envelope(status="error", reason="invalid_input: нужен --file или --project",
2299
+ metadata=_privacy_metadata({"engine": "deck"}, args))
2300
+ _write_output(_dump_envelope(envelope, args), args)
2301
+ return 2
2302
+ out = getattr(args, "out", None) or "deck.pptx"
2303
+ title = getattr(args, "title", None) or "Подборка недвижимости"
2304
+ try:
2305
+ res = deck_from_cards(cards, out, title=title)
2306
+ except Exception as exc:
2307
+ envelope = make_envelope(status="error", reason=f"deck_failed: {type(exc).__name__}",
2308
+ metadata=_privacy_metadata({"engine": "deck"}, args))
2309
+ _write_output(_dump_envelope(envelope, args), args)
2310
+ return 3
2311
+ envelope = make_envelope(status="ok", source="deck", data={"result": res,
2312
+ "text": f"PPTX: {res['out']}, слайдов {res['slides']}, карточек {res['cards']} (cap 15)"},
2313
+ metadata=_privacy_metadata({"engine": "deck", "cached": False, "elapsed_seconds": 0.0}, args))
2314
+ _write_output(_dump_envelope(envelope, args), args)
2315
+ return 0
2316
+
2317
+
2318
+ _EXAMPLES = {
2319
+ "search": 'пример: nedviga-cli search --geo-districts ЮАО --price-max 20000000 --out cards.json',
2320
+ "get-card": 'пример: nedviga-cli get-card cian-333714893 --json cards.json --purpose "прозвон" --with-contacts',
2321
+ "card": 'пример: nedviga-cli card cards.json --format all --out ./out',
2322
+ "export": 'пример: nedviga-cli export --input cards.json --output katalog.html',
2323
+ "pipeline": 'пример: nedviga-cli pipeline set cian-1 --status shortlist; pipeline list',
2324
+ "watch": 'пример: nedviga-cli watch add --file cards.json; watch check --offline fresh.json',
2325
+ "digest": 'пример: nedviga-cli digest --days 7 --format telegram',
2326
+ "compare": 'пример: nedviga-cli compare --file cards.json --format telegram',
2327
+ "bargain": 'пример: nedviga-cli bargain --file target.json --corpus corpus.json --by-complex',
2328
+ "risk": 'пример: nedviga-cli risk --file card.json',
2329
+ "triage": 'пример: nedviga-cli triage --file card.json',
2330
+ "checklist": 'пример: nedviga-cli checklist new cian-1 --file cards.json; checklist handoff cian-1 --to colleague',
2331
+ "callsheet": 'пример: nedviga-cli callsheet --file cards.json',
2332
+ "project": 'пример: nedviga-cli project new "ЮАО" --query "{}"; project refresh P1 --file new.json',
2333
+ "dedup": 'пример: nedviga-cli dedup --file cards.json',
2334
+ "tour": 'пример: nedviga-cli tour --project P1 --all --format telegram',
2335
+ "poi": 'пример: nedviga-cli poi --file card.json --radius-min 15',
2336
+ "market": 'пример: nedviga-cli market --corpus f1.json,f2.json --group-by complex',
2337
+ "stale": 'пример: nedviga-cli stale --file cards.json --project P1',
2338
+ "catalog-pdf": 'пример: nedviga-cli catalog-pdf --file cards.json --out katalog.pdf',
2339
+ "auto": 'пример: nedviga-cli auto run --file corpus.json; auto report --days 7',
2340
+ "litcheck": 'пример: nedviga-cli litcheck --address "ул. Кантемировская, 20" [--developer "ПИК"]',
2341
+ "eco": 'пример: nedviga-cli eco --file card.json',
2342
+ "mos": 'пример: nedviga-cli mos status (env NEDVIGA_EGRESS_PROXY, NEDVIGA_MOS_TOKEN_FILE)',
2343
+ "dossier": 'пример: nedviga-cli dossier sync --dataset 3293; dossier match --address "улица Азовская, дом 4"',
2344
+ "developer": 'пример: nedviga-cli developer --inn 770123456789 --litcheck',
2345
+ "noise": 'пример: nedviga-cli noise sync; noise --lat 55.72 --lon 37.61 --radius-m 1500',
2346
+ "avito": 'пример: nedviga-cli avito parse --file livetext.txt; search --source avito --catalog URL --live',
2347
+ "source": 'пример: nedviga-cli source (лицензия, репо, как получить исходники)',
2348
+ "domclick": 'пример: nedviga-cli domclick parse --file dc_text.txt; search --source domclick --catalog URL --live',
2349
+ "rush": 'пример: nedviga-cli rush --file card.json [--hour 18] (балл пробок и почасовой web-прогноз только с NEDVIGA_EGRESS_PROXY)',
2350
+ "deck": 'пример: nedviga-cli deck --file cards.json --out подборока.pptx [--title "ЮАО"]',
2351
+ "sources": 'пример: nedviga-cli sources list',
2352
+ "text": 'пример: nedviga-cli text index --file cards.json; text query "узакон*"',
2353
+ "feedback": 'пример: nedviga-cli feedback add cian-1 --verdict liked --reason "район"; feedback profile',
2354
+ "chain": 'пример: nedviga-cli chain new cian-1 --kind alt_unfound; chain show cian-1',
2355
+ "scorefit": 'пример: nedviga-cli scorefit --file cards.json --profile P1',
2356
+ "wizard": 'пример: nedviga-cli wizard init --profile family --out ./kit; wizard doctor',
2357
+ "ops": 'пример: nedviga-cli ops; ops --precheck --live', "import": 'пример: nedviga-cli import --file export.html --format cian-html',
2358
+ "walk-distance": 'пример: nedviga-cli walk-distance --from "м. Царицыно" --to card.json',
2359
+ "audit": 'пример: nedviga-cli audit --days 7 --role agent',
2360
+ "pii-log": 'пример: nedviga-cli pii-log --days 30',
2361
+ "purge": 'пример: nedviga-cli purge card cian-1 (dry); purge card cian-1 --yes',
2362
+ "mcp": 'пример: nedviga-cli mcp serve (stdio JSON-RPC, контакты закрыты принципиально)',
2363
+ "doctor": 'пример: nedviga-cli doctor; doctor --verify-signature <файл.whl>',
2364
+ }
2365
+
2366
+
2367
+ def _annotate_examples(subparsers) -> None:
2368
+ """v0.5.11 контроль: каждой подкоманде — пример в help (epilog)."""
2369
+ name_map = getattr(subparsers, "_name_parser_map", {}) or {}
2370
+ for name, parser in name_map.items():
2371
+ ex = _EXAMPLES.get(name)
2372
+ if ex and "пример:" not in ((parser.description or "") + (parser.epilog or "")):
2373
+ parser.epilog = ((parser.epilog + "\n") if parser.epilog else "") + ex
2374
+
2375
+
2376
+ _subparsers_ref = []
2377
+
2378
+
2379
+ def _build_parser() -> _NedvigaArgumentParser:
2380
+ parser = _NedvigaArgumentParser(
2381
+ prog="nedviga-cli",
2382
+ description="Moscow secondary real estate CLI/SDK",
2383
+ )
2384
+ parser.add_argument(
2385
+ "--version",
2386
+ action="version",
2387
+ version=f"%(prog)s {_package_version()}",
2388
+ )
2389
+ parser.add_argument(
2390
+ "--no-spotlight",
2391
+ action="store_true",
2392
+ help="Не оборачивать внешний текстовый контент карточек в spotlight-маркеры",
2393
+ )
2394
+
2395
+ sub = parser.add_subparsers(dest="command", required=True)
2396
+ # search
2397
+ search = sub.add_parser("search", help="Search listings")
2398
+ search.add_argument("--no-spotlight", action="store_true",
2399
+ help="Не оборачивать внешний текст карточек в spotlight-маркеры")
2400
+ search.add_argument("--source", default=None)
2401
+ search.add_argument("--deal-type", default=None)
2402
+ search.add_argument("--rooms", type=int, default=None)
2403
+ search.add_argument("--price-min", type=float, default=None)
2404
+ search.add_argument("--price-max", type=float, default=None)
2405
+ search.add_argument("--kitchen-min", type=float, default=None)
2406
+ search.add_argument("--floor-not-first", action="store_true")
2407
+ search.add_argument("--min-house-floors", type=int, default=None)
2408
+ search.add_argument("--page", type=int, default=1, help="Fetch a specific result page (default 1)")
2409
+ search.add_argument("--pages", type=int, default=None, help="Fetch pages 1..N sequentially")
2410
+ search.add_argument("--metro-walk-max", type=int, default=None, help="Server-side max walking time to metro, minutes")
2411
+ search.add_argument("--hydrate", action="store_true", help="Fetch full cards for each search result")
2412
+ search.add_argument("--no-auction", action="store_true", help="Exclude auction (torgi) listings")
2413
+ search.add_argument("--only-auction", action="store_true", help="Keep only auction (torgi) listings")
2414
+ search.add_argument("--workers", type=int, default=4, help="Parallel workers for --hydrate (default 4)")
2415
+ search.add_argument("--geo-districts", default=None)
2416
+ search.add_argument("--limit", type=int, default=None)
2417
+ # v0.4.0 (ТЗ реалтора): боевые фильтры риелтора
2418
+ search.add_argument(
2419
+ "--strict-mkad", action="store_true", dest="strict_mkad",
2420
+ help="Exclude addresses outside strict MKAD (NAO/TAO/ZelAOO and near towns)",
2421
+ )
2422
+ search.add_argument(
2423
+ "--only-flat", action="store_true", dest="only_flat",
2424
+ help="Exclude apartments (апартаменты)",
2425
+ )
2426
+ search.add_argument(
2427
+ "--no-apartments", action="store_true", dest="only_flat",
2428
+ help="Alias for --only-flat",
2429
+ )
2430
+ search.add_argument(
2431
+ "--no-shares", action="store_true", dest="no_shares",
2432
+ help="Exclude flat shares and rooms (доля в квартире)",
2433
+ )
2434
+ search.add_argument(
2435
+ "--no-gas", action="store_true", dest="no_gas",
2436
+ help="Alias for --min-house-floors 14 (no gas in 14+ floor buildings)",
2437
+ )
2438
+ search.add_argument(
2439
+ "--exclude-legal-risks", action="store_true", dest="exclude_legal_risks",
2440
+ help="Exclude listings with legal-risk stop phrases in description",
2441
+ )
2442
+ # v0.5.4 (the task brief) overrides
2443
+ search.add_argument(
2444
+ "--use-overrides", action="store_true", dest="use_overrides",
2445
+ help="Apply learned profile overrides",
2446
+ )
2447
+ # v0.5.3 (the task brief) relax hints
2448
+ search.add_argument(
2449
+ "--suggest-relax", action="store_true", dest="suggest_relax",
2450
+ help="Подсказать смягчение критериев при пустой выдаче",
2451
+ )
2452
+ # v0.5.0 (the task brief) projects
2453
+ search.add_argument(
2454
+ "--project", default=None, dest="project",
2455
+ help="Автоматически добавлять результаты в проект",
2456
+ )
2457
+ # v0.4.6 (the task brief) F-13/F-03
2458
+ search.add_argument(
2459
+ "--purpose", default=None, help="Purpose for PII access",
2460
+ )
2461
+ search.add_argument(
2462
+ "--with-contacts", action="store_true", dest="with_contacts",
2463
+ help="Не маскировать телефоны в выводе (по умолчанию маскируются; "
2464
+ "в metadata ставится contacts_open: true)",
2465
+ )
2466
+ search.add_argument(
2467
+ "--profile", default=None, dest="profile",
2468
+ help="Именованный пресет поиска (parents, lnfnt); явно переданные "
2469
+ "аргументы CLI переопределяют профиль",
2470
+ )
2471
+ search.add_argument(
2472
+ "--json",
2473
+ nargs="?",
2474
+ const="-",
2475
+ default=None,
2476
+ help="Write JSON result to FILE; default stdout when flag present",
2477
+ )
2478
+ search.add_argument("--catalog", default=None, help="Avito catalog URL")
2479
+ search.add_argument("--max", type=int, default=20, help="Max results")
2480
+ search.add_argument("--live", action="store_true", help="Live browser search")
2481
+ search.add_argument("--input", default=None, help="Offline fixture file")
2482
+ search.set_defaults(func=_cmd_search)
2483
+
2484
+ # get-card
2485
+ get_card = sub.add_parser("get-card", help="Fetch a single listing card")
2486
+ get_card.add_argument("--no-spotlight", action="store_true",
2487
+ help="Не оборачивать внешний текст карточки в spotlight-маркеры")
2488
+ get_card.add_argument("target", help="URL or listing ID")
2489
+ get_card.add_argument(
2490
+ "--with-walk",
2491
+ dest="with_walk",
2492
+ action="store_true",
2493
+ help="Compute honest walk route to claimed metro and embed it in the card",
2494
+ )
2495
+ get_card.add_argument(
2496
+ "--with-contacts", action="store_true", dest="with_contacts",
2497
+ help="Не маскировать телефоны в выводе get-card",
2498
+ )
2499
+ get_card.add_argument("--purpose", default=None, help="Purpose for PII access")
2500
+ get_card.add_argument(
2501
+ "--json",
2502
+ nargs="?",
2503
+ const="-",
2504
+ default=None,
2505
+ help="Write JSON result to FILE; default stdout when flag present",
2506
+ )
2507
+ get_card.set_defaults(func=_cmd_get_card)
2508
+
2509
+ # card (F-11 family decision card)
2510
+ card_cmd = sub.add_parser("card", help="Generate family decision card")
2511
+ card_cmd.add_argument("target", help="Path to JSON card file or listing id")
2512
+ card_cmd.add_argument("--format", default="html", choices=["html", "pdf", "telegram", "all"])
2513
+ card_cmd.add_argument("--out", default=None, help="Output directory")
2514
+ card_cmd.add_argument("--output", default=None, help="Output directory (alias)")
2515
+ card_cmd.add_argument("--with-contacts", action="store_true", dest="with_contacts",
2516
+ help="Открыть контакты продавца")
2517
+ card_cmd.add_argument("--purpose", default=None, help="Purpose for PII access")
2518
+ card_cmd.add_argument(
2519
+ "--json",
2520
+ nargs="?",
2521
+ const="-",
2522
+ default=None,
2523
+ help="Write JSON result to FILE; default stdout when flag present",
2524
+ )
2525
+ card_cmd.set_defaults(func=_cmd_card)
2526
+
2527
+ # export (v0.3.4)
2528
+ export_cmd = sub.add_parser("export", help="Export cards to standalone HTML catalog")
2529
+ export_cmd.add_argument("--input", required=True, help="JSON file with cards (list or {cards: [...]})")
2530
+ export_cmd.add_argument("--format", default="html", choices=["html"])
2531
+ export_cmd.add_argument("--embed-photos", dest="embed_photos", action="store_true",
2532
+ help="Embed compressed photos as base64 (offline catalog)")
2533
+ export_cmd.add_argument("--output", default="catalog.html", help="Output HTML file path")
2534
+ # v0.4.6 (the task brief) F-13
2535
+ export_cmd.add_argument(
2536
+ "--with-contacts", action="store_true", dest="with_contacts",
2537
+ help="Не маскировать телефоны в HTML-каталоге (по умолчанию маскируются; "
2538
+ "в metadata ставится contacts_open: true)",
2539
+ )
2540
+ export_cmd.add_argument("--purpose", default=None, help="Purpose for PII access")
2541
+ export_cmd.add_argument(
2542
+ "--json",
2543
+ nargs="?",
2544
+ const="-",
2545
+ default=None,
2546
+ help="Write JSON result to FILE; default stdout when flag present",
2547
+ )
2548
+ export_cmd.set_defaults(func=_cmd_export)
2549
+
2550
+ # import
2551
+ import_cmd = sub.add_parser("import", help="Parse a saved HTML file offline")
2552
+ import_cmd.add_argument("--no-spotlight", action="store_true",
2553
+ help="Не оборачивать внешний текст карточек в spotlight-маркеры")
2554
+ import_cmd.add_argument("path", help="Path to saved HTML file")
2555
+ import_cmd.add_argument(
2556
+ "--with-walk",
2557
+ dest="with_walk",
2558
+ action="store_true",
2559
+ help="Compute honest walk route to claimed metro and embed it in the card",
2560
+ )
2561
+ import_cmd.add_argument(
2562
+ "--json",
2563
+ nargs="?",
2564
+ const="-",
2565
+ default=None,
2566
+ help="Write JSON result to FILE; default stdout when flag present",
2567
+ )
2568
+ import_cmd.set_defaults(func=_cmd_import)
2569
+
2570
+ # walk-distance
2571
+ walk = sub.add_parser("walk-distance", help="Honest walking distance to metro")
2572
+ walk.add_argument("--lat", type=float, required=True)
2573
+ walk.add_argument("--lon", type=float, required=True)
2574
+ walk.add_argument("--metro", required=True)
2575
+ walk.add_argument(
2576
+ "--claimed-time",
2577
+ type=int,
2578
+ default=None,
2579
+ help="Platform-claimed walking time in minutes (optional)",
2580
+ )
2581
+ walk.add_argument(
2582
+ "--json",
2583
+ nargs="?",
2584
+ const="-",
2585
+ default=None,
2586
+ help="Write JSON result to FILE; default stdout when flag present",
2587
+ )
2588
+ walk.set_defaults(func=_cmd_walk_distance)
2589
+
2590
+ # pipeline (F-04)
2591
+ pipeline = sub.add_parser("pipeline", help="Object funnel pipeline")
2592
+ psub = pipeline.add_subparsers(dest="pipeline_action", required=True)
2593
+ pset = psub.add_parser("set", help="Set pipeline status")
2594
+ pset.add_argument("card_id", help="Card id")
2595
+ pset.add_argument("--status", required=True, help="New status")
2596
+ pset.add_argument("--note", default=None, help="Note")
2597
+ pset.add_argument("--from-file", dest="from_file", default=None, help="JSON file with cards")
2598
+ pset.set_defaults(func=_cmd_pipeline)
2599
+ plist = psub.add_parser("list", help="List pipeline items")
2600
+ plist.add_argument("--status", default=None, help="Filter by status")
2601
+ plist.set_defaults(func=_cmd_pipeline)
2602
+ pshow = psub.add_parser("show", help="Show pipeline history")
2603
+ pshow.add_argument("card_id", help="Card id")
2604
+ pshow.set_defaults(func=_cmd_pipeline)
2605
+ pipeline.set_defaults(func=_cmd_pipeline)
2606
+
2607
+ # watch (F-08)
2608
+ watch = sub.add_parser("watch", help="Monitor listings")
2609
+ wsub = watch.add_subparsers(dest="watch_action", required=True)
2610
+ wadd = wsub.add_parser("add", help="Add snapshot")
2611
+ wadd.add_argument("card_id", nargs="?", default=None, help="Card id")
2612
+ wadd.add_argument("--file", default=None, help="JSON file with cards")
2613
+ wadd.add_argument("--price", type=float, default=None, help="Price snapshot")
2614
+ wadd.set_defaults(func=_cmd_watch)
2615
+ wlist = wsub.add_parser("list", help="List snapshots")
2616
+ wlist.set_defaults(func=_cmd_watch)
2617
+ wrm = wsub.add_parser("rm", help="Remove snapshot")
2618
+ wrm.add_argument("card_id", help="Card id")
2619
+ wrm.set_defaults(func=_cmd_watch)
2620
+ wcheck = wsub.add_parser("check", help="Check for changes")
2621
+ wcheck.add_argument("--offline", default=None, help="Offline fresh export file")
2622
+ wcheck.set_defaults(func=_cmd_watch)
2623
+ watch.set_defaults(func=_cmd_watch)
2624
+
2625
+ # digest (F-14b)
2626
+ digest = sub.add_parser("digest", help="Family digest")
2627
+ digest.add_argument("--days", type=int, default=7, help="Period days")
2628
+ digest.add_argument("--format", default="telegram", choices=["telegram", "md"])
2629
+ digest.add_argument("--out", default=None, help="Output file")
2630
+ digest.add_argument("--output", default=None, help="Output file (alias)")
2631
+ digest.set_defaults(func=_cmd_digest)
2632
+
2633
+ # compare (F-12)
2634
+ compare = sub.add_parser("compare", help="Compare listings")
2635
+ compare.add_argument("--file", required=True, help="JSON file with cards")
2636
+ compare.add_argument("--ids", default=None, help="Comma-separated ids")
2637
+ compare.add_argument("--format", default="md", choices=["md", "telegram", "html"])
2638
+ compare.add_argument("--by-fit", action="store_true", dest="by_fit", help="Sort by fit score")
2639
+ compare.add_argument("--out", default=None, help="Output file")
2640
+ compare.add_argument("--output", default=None, help="Output file (alias)")
2641
+ compare.set_defaults(func=_cmd_compare)
2642
+
2643
+ # bargain (F-10b)
2644
+ bargain = sub.add_parser("bargain", help="Bargain analysis")
2645
+ bargain.add_argument("--file", required=True, help="Target card JSON")
2646
+ bargain.add_argument("--corpus", required=True, help="Corpus JSON file(s), comma-separated")
2647
+ bargain.add_argument("--district", default=None, help="District override")
2648
+ bargain.add_argument("--rooms", type=int, default=None, help="Rooms override")
2649
+ bargain.add_argument("--by-complex", action="store_true", dest="by_complex", help="Group by complex")
2650
+ bargain.add_argument("--out", default=None, help="Output file")
2651
+ bargain.add_argument("--output", default=None, help="Output file (alias)")
2652
+ bargain.set_defaults(func=_cmd_bargain)
2653
+
2654
+ # risk (F-07b/F-19)
2655
+ risk = sub.add_parser("risk", help="Risk scoring")
2656
+ risk.add_argument("--file", required=True, help="Card JSON")
2657
+ risk.add_argument("--corpus", default=None, help="Corpus JSON for price signals")
2658
+ risk.add_argument("--district", default=None, help="District override")
2659
+ risk.add_argument("--rooms", type=int, default=None, help="Rooms override")
2660
+ risk.add_argument("--out", default=None, help="Output file")
2661
+ risk.add_argument("--output", default=None, help="Output file (alias)")
2662
+ risk.set_defaults(func=_cmd_risk)
2663
+
2664
+ # triage (F-06b)
2665
+ triage = sub.add_parser("triage", help="Legal red flags triage")
2666
+ triage.add_argument("--file", required=True, help="Card JSON")
2667
+ triage.add_argument("--out", default=None, help="Output file")
2668
+ triage.add_argument("--output", default=None, help="Output file (alias)")
2669
+ triage.set_defaults(func=_cmd_triage)
2670
+
2671
+ # checklist (F-22)
2672
+ checklist = sub.add_parser("checklist", help="Legal checklist")
2673
+ csub = checklist.add_subparsers(dest="checklist_action", required=True)
2674
+ cnew = csub.add_parser("new", help="New checklist")
2675
+ cnew.add_argument("card_id", help="Card id")
2676
+ cnew.add_argument("--file", required=True, help="Cards JSON")
2677
+ cnew.add_argument("--out", default=None, help="Output file")
2678
+ cnew.add_argument("--output", default=None, help="Output file (alias)")
2679
+ cnew.set_defaults(func=_cmd_checklist)
2680
+ chand = csub.add_parser("handoff", help="Handoff to colleague")
2681
+ chand.add_argument("card_id", help="Card id")
2682
+ chand.add_argument("--to", default="colleague", help="Recipient")
2683
+ chand.add_argument("--file", default=None, help="Cards JSON")
2684
+ chand.set_defaults(func=_cmd_checklist)
2685
+ checklist.set_defaults(func=_cmd_checklist)
2686
+
2687
+ # callsheet (F-09c)
2688
+ callsheet = sub.add_parser("callsheet", help="Call sheet")
2689
+ callsheet.add_argument("--file", required=True, help="Cards JSON")
2690
+ callsheet.add_argument("--out", default=None, help="Output file")
2691
+ callsheet.add_argument("--output", default=None, help="Output file (alias)")
2692
+ callsheet.add_argument("--with-contacts", action="store_true", dest="with_contacts")
2693
+ callsheet.add_argument("--purpose", default=None, help="Purpose for PII access")
2694
+ callsheet.set_defaults(func=_cmd_callsheet)
2695
+
2696
+ # project (F-06)
2697
+ project = sub.add_parser("project", help="Projects")
2698
+ psub = project.add_subparsers(dest="project_action", required=True)
2699
+ pnew = psub.add_parser("new", help="New project")
2700
+ pnew.add_argument("name", help="Project name")
2701
+ pnew.add_argument("--query", default=None, help="Query JSON")
2702
+ pnew.set_defaults(func=_cmd_project)
2703
+ plist = psub.add_parser("list", help="List projects")
2704
+ plist.set_defaults(func=_cmd_project)
2705
+ padd = psub.add_parser("add", help="Add card to project")
2706
+ padd.add_argument("project", help="Project id")
2707
+ padd.add_argument("card_id", help="Card id")
2708
+ padd.set_defaults(func=_cmd_project)
2709
+ popen = psub.add_parser("open", help="Open project")
2710
+ popen.add_argument("project", help="Project id")
2711
+ popen.add_argument("--out", default=None, help="Output file")
2712
+ popen.add_argument("--output", default=None, help="Output file (alias)")
2713
+ popen.set_defaults(func=_cmd_project)
2714
+ prefresh = psub.add_parser("refresh", help="Refresh project")
2715
+ prefresh.add_argument("project", help="Project id")
2716
+ prefresh.add_argument("--file", required=True, help="New search JSON")
2717
+ prefresh.set_defaults(func=_cmd_project)
2718
+ project.set_defaults(func=_cmd_project)
2719
+
2720
+ # poi (F-14)
2721
+ poi = sub.add_parser("poi", help="POI accessibility")
2722
+ poi.add_argument("--file", required=True, help="Card JSON")
2723
+ poi.add_argument("--radius-min", type=int, default=15, dest="radius_min")
2724
+ poi.add_argument("--offline", default=None, help="Offline Overpass fixture")
2725
+ poi.add_argument("--out", default=None, help="Output file")
2726
+ poi.add_argument("--output", default=None, help="Output file (alias)")
2727
+ poi.set_defaults(func=_cmd_poi)
2728
+
2729
+ # tour (F-09)
2730
+ tour = sub.add_parser("tour", help="Tour routing")
2731
+ tour.add_argument("--project", default=None, help="Project id")
2732
+ tour.add_argument("--file", default=None, help="Cards JSON")
2733
+ tour.add_argument("--date", default=None, help="Date")
2734
+ tour.add_argument("--slot-min", type=int, default=45, dest="slot_min")
2735
+ tour.add_argument("--start", default="10:00", help="Start time")
2736
+ tour.add_argument("--all", action="store_true", dest="all", help="Include all statuses")
2737
+ tour.add_argument("--format", default="telegram", choices=["telegram", "md"])
2738
+ tour.add_argument("--out", default=None, help="Output file")
2739
+ tour.add_argument("--output", default=None, help="Output file (alias)")
2740
+ tour.set_defaults(func=_cmd_tour)
2741
+
2742
+ # sources (F-53)
2743
+ sources = sub.add_parser("sources", help="Source adapters")
2744
+ ssub = sources.add_subparsers(dest="sources_action", required=False)
2745
+ slist = ssub.add_parser("list", help="List sources")
2746
+ slist.set_defaults(func=_cmd_sources)
2747
+ sources.set_defaults(func=_cmd_sources, sources_action="list")
2748
+
2749
+ # dedup (F-02)
2750
+ dedup = sub.add_parser("dedup", help="Deduplicate listings")
2751
+ dedup.add_argument("--file", required=True, help="Cards JSON")
2752
+ dedup.add_argument("--strict", action="store_true", help="Require >=2 signals")
2753
+ dedup.add_argument("--out", default=None, help="Output file")
2754
+ dedup.add_argument("--output", default=None, help="Output file (alias)")
2755
+ dedup.set_defaults(func=_cmd_dedup)
2756
+
2757
+ # market (F-11)
2758
+ market = sub.add_parser("market", help="Market analytics")
2759
+ market.add_argument("--corpus", required=True, help="Corpus JSON file(s), comma-separated")
2760
+ market.add_argument("--district", default=None, help="District filter")
2761
+ market.add_argument("--okrug", default=None, help="Okrug filter")
2762
+ market.add_argument("--group-by", default="district", choices=["district", "complex"], dest="group_by")
2763
+ market.add_argument("--days", type=int, default=30, help="Period days")
2764
+ market.add_argument("--format", default="telegram", choices=["telegram", "md"])
2765
+ market.add_argument("--out", default=None, help="Output file")
2766
+ market.add_argument("--output", default=None, help="Output file (alias)")
2767
+ market.set_defaults(func=_cmd_market)
2768
+
2769
+ # stale (F-58)
2770
+ stale = sub.add_parser("stale", help="Stale detection")
2771
+ stale.add_argument("--file", default=None, help="Cards JSON")
2772
+ stale.add_argument("--project", default=None, help="Project id")
2773
+ stale.add_argument("--max-age-days", type=int, default=14, dest="max_age_days")
2774
+ stale.add_argument("--verify-live", action="store_true", dest="verify_live")
2775
+ stale.add_argument("--out", default=None, help="Output file")
2776
+ stale.add_argument("--output", default=None, help="Output file (alias)")
2777
+ stale.set_defaults(func=_cmd_stale)
2778
+
2779
+ # catalog-pdf (F-31)
2780
+ catalog_pdf = sub.add_parser("catalog-pdf", help="Catalog PDF")
2781
+ catalog_pdf.add_argument("--file", default=None, help="Cards JSON")
2782
+ catalog_pdf.add_argument("--project", default=None, help="Project id")
2783
+ catalog_pdf.add_argument("--out", default="katalog.pdf", help="Output PDF")
2784
+ catalog_pdf.add_argument("--output", default=None, help="Output PDF (alias)")
2785
+ catalog_pdf.add_argument("--title", default=None, help="Title")
2786
+ catalog_pdf.set_defaults(func=_cmd_catalog_pdf)
2787
+
2788
+ # feedback (F-21)
2789
+ feedback = sub.add_parser("feedback", help="Feedback tracker")
2790
+ fsub = feedback.add_subparsers(dest="feedback_action", required=True)
2791
+ fadd = fsub.add_parser("add", help="Add feedback")
2792
+ fadd.add_argument("card_id", help="Card id")
2793
+ fadd.add_argument("--verdict", required=True, choices=["liked", "rejected", "maybe"])
2794
+ fadd.add_argument("--reason", default=None, help="Reason")
2795
+ fadd.add_argument("--note", default=None, help="Note")
2796
+ fadd.add_argument("--dry", action="store_true", help="Dry run")
2797
+ fadd.set_defaults(func=_cmd_feedback)
2798
+ flist = fsub.add_parser("list", help="List feedback")
2799
+ flist.add_argument("--card", default=None, help="Card id filter")
2800
+ flist.set_defaults(func=_cmd_feedback)
2801
+ fprof = fsub.add_parser("profile", help="Feedback profile")
2802
+ fprof.set_defaults(func=_cmd_feedback)
2803
+ fapply = fsub.add_parser("apply", help="Apply overrides")
2804
+ fapply.add_argument("--dry", action="store_true", help="Dry run")
2805
+ fapply.add_argument("--yes", action="store_true", help="Apply")
2806
+ fapply.set_defaults(func=_cmd_feedback)
2807
+ feedback.set_defaults(func=_cmd_feedback)
2808
+
2809
+ # scorefit (F-G3)
2810
+ scorefit = sub.add_parser("scorefit", help="Score fit")
2811
+ scorefit.add_argument("--file", required=True, help="Cards JSON")
2812
+ scorefit.add_argument("--profile", default=None, help="Profile JSON or name")
2813
+ scorefit.add_argument("--out", default=None, help="Output file")
2814
+ scorefit.add_argument("--output", default=None, help="Output file (alias)")
2815
+ scorefit.set_defaults(func=_cmd_scorefit)
2816
+
2817
+ # chain (F-J2)
2818
+ chain = sub.add_parser("chain", help="Deal chains")
2819
+ csub = chain.add_subparsers(dest="chain_action", required=True)
2820
+ cnew = csub.add_parser("new", help="New chain")
2821
+ cnew.add_argument("card_id", help="Card id")
2822
+ cnew.add_argument("--kind", default="direct", choices=["direct", "alt_found", "alt_unfound", "guardian_needed"])
2823
+ cnew.set_defaults(func=_cmd_chain)
2824
+ clink = csub.add_parser("link", help="Link cards")
2825
+ clink.add_argument("a", help="Card a")
2826
+ clink.add_argument("b", help="Card b")
2827
+ clink.add_argument("--rel", default="depends_on", choices=["depends_on", "blocks"])
2828
+ clink.set_defaults(func=_cmd_chain)
2829
+ cshow = csub.add_parser("show", help="Show chain")
2830
+ cshow.add_argument("card_id", help="Card id")
2831
+ cshow.set_defaults(func=_cmd_chain)
2832
+ chain.set_defaults(func=_cmd_chain)
2833
+
2834
+ # text (F-05)
2835
+ text = sub.add_parser("text", help="Full-text search")
2836
+ tsub = text.add_subparsers(dest="text_action", required=True)
2837
+ tindex = tsub.add_parser("index", help="Index file")
2838
+ tindex.add_argument("--file", required=True, help="Cards JSON")
2839
+ tindex.add_argument("--project", default=None, help="Project id")
2840
+ tindex.set_defaults(func=_cmd_text)
2841
+ tquery = tsub.add_parser("query", help="Query")
2842
+ tquery.add_argument("query", help="FTS query")
2843
+ tquery.add_argument("--project", default=None, help="Project id")
2844
+ tquery.set_defaults(func=_cmd_text)
2845
+ text.set_defaults(func=_cmd_text)
2846
+
2847
+ # audit (F-45)
2848
+ audit = sub.add_parser("audit", help="Audit log")
2849
+ audit.add_argument("--days", type=int, default=30)
2850
+ audit.add_argument("--role", default=None)
2851
+ audit.add_argument("--card", default=None)
2852
+ audit.set_defaults(func=_cmd_audit)
2853
+
2854
+ # pii-log (F-38)
2855
+ pii_log = sub.add_parser("pii-log", help="PII access log")
2856
+ pii_log.add_argument("--days", type=int, default=30)
2857
+ pii_log.set_defaults(func=_cmd_pii_log)
2858
+
2859
+ # purge (F-43)
2860
+ purge = sub.add_parser("purge", help="Purge data")
2861
+ psub = purge.add_subparsers(dest="purge_action", required=True)
2862
+ pcard = psub.add_parser("card", help="Purge card")
2863
+ pcard.add_argument("card_id", help="Card id")
2864
+ pcard.add_argument("--yes", action="store_true", help="Execute")
2865
+ pcard.set_defaults(func=_cmd_purge)
2866
+ pprof = psub.add_parser("profile", help="Purge profile")
2867
+ pprof.add_argument("name", help="Profile name")
2868
+ pprof.add_argument("--yes", action="store_true", help="Execute")
2869
+ pprof.set_defaults(func=_cmd_purge)
2870
+ purge.set_defaults(func=_cmd_purge)
2871
+
2872
+ # auto (F-20)
2873
+ auto = sub.add_parser("auto", help="Auto selection")
2874
+ asub = auto.add_subparsers(dest="auto_action", required=True)
2875
+ arun = asub.add_parser("run", help="Run auto selection")
2876
+ arun.add_argument("--profile", default=None, help="Profile JSON or name")
2877
+ arun.add_argument("--file", default=None, help="Corpus JSON")
2878
+ arun.add_argument("--project", default=None, help="Project id")
2879
+ arun.add_argument("--live", action="store_true", help="Live search")
2880
+ arun.add_argument("--out", default=None, help="Output file")
2881
+ arun.add_argument("--output", default=None, help="Output file (alias)")
2882
+ arun.set_defaults(func=_cmd_auto)
2883
+ areport = asub.add_parser("report", help="Auto report")
2884
+ areport.add_argument("--days", type=int, default=7)
2885
+ areport.add_argument("--out", default=None, help="Output file")
2886
+ areport.add_argument("--output", default=None, help="Output file (alias)")
2887
+ areport.set_defaults(func=_cmd_auto)
2888
+ auto.set_defaults(func=_cmd_auto)
2889
+
2890
+ # litcheck (F-22b)
2891
+ litcheck = sub.add_parser("litcheck", help="Litigation pre-check")
2892
+ litcheck.add_argument("--card", default=None, help="Card id")
2893
+ litcheck.add_argument("--file", default=None, help="Cards JSON")
2894
+ litcheck.add_argument("--address", default=None, help="Address")
2895
+ litcheck.add_argument("--developer", default=None, help="Developer")
2896
+ litcheck.add_argument("--since", type=int, default=2020, help="Since year")
2897
+ litcheck.add_argument("--out", default=None, help="Output file")
2898
+ litcheck.add_argument("--output", default=None, help="Output file (alias)")
2899
+ litcheck.set_defaults(func=_cmd_litcheck)
2900
+
2901
+ # eco (F-16)
2902
+ eco = sub.add_parser("eco", help="Eco proxy")
2903
+ eco.add_argument("--file", required=True, help="Card JSON")
2904
+ eco.add_argument("--offline", default=None, help="Offline Overpass fixture")
2905
+ eco.add_argument("--out", default=None, help="Output file")
2906
+ eco.add_argument("--output", default=None, help="Output file (alias)")
2907
+ eco.set_defaults(func=_cmd_eco)
2908
+
2909
+ # ops (F-44/46)
2910
+ ops = sub.add_parser("ops", help="Operator panel")
2911
+ ops.add_argument("--days", type=int, default=1)
2912
+ ops.add_argument("--format", default="telegram", choices=["telegram", "json"])
2913
+ ops.add_argument("--precheck", action="store_true")
2914
+ ops.add_argument("--live", action="store_true")
2915
+ ops.add_argument("--json", nargs="?", const="-", default=None)
2916
+ ops.add_argument("--out", default=None)
2917
+ ops.add_argument("--output", default=None)
2918
+ ops.set_defaults(func=_cmd_ops)
2919
+
2920
+ # mcp (F-23)
2921
+ mcp = sub.add_parser("mcp", help="MCP server")
2922
+ msub = mcp.add_subparsers(dest="mcp_action", required=False)
2923
+ mserve = msub.add_parser("serve", help="Serve stdio")
2924
+ mserve.set_defaults(func=_cmd_mcp)
2925
+ mcp.set_defaults(func=_cmd_mcp, mcp_action="serve")
2926
+
2927
+ # mos (F-04b)
2928
+ mos = sub.add_parser("mos", help="Mosdata client")
2929
+ msub = mos.add_subparsers(dest="mos_action", required=False)
2930
+ ms = msub.add_parser("search-datasets", help="Search datasets")
2931
+ ms.add_argument("--query", required=True)
2932
+ ms.set_defaults(func=_cmd_mos, mos_action="search-datasets")
2933
+ mv = msub.add_parser("dataset-versions", help="Dataset versions")
2934
+ mv.add_argument("dataset_id")
2935
+ mv.set_defaults(func=_cmd_mos, mos_action="dataset-versions")
2936
+ mr = msub.add_parser("rows", help="Rows")
2937
+ mr.add_argument("dataset_id")
2938
+ mr.add_argument("--version", default=None)
2939
+ mr.add_argument("--top", type=int, default=1000)
2940
+ mr.add_argument("--skip", type=int, default=0)
2941
+ mr.set_defaults(func=_cmd_mos, mos_action="rows")
2942
+ mst = msub.add_parser("status", help="Status")
2943
+ mst.set_defaults(func=_cmd_mos, mos_action="status")
2944
+ mos.set_defaults(func=_cmd_mos, mos_action="status")
2945
+
2946
+ # dossier (F-04b)
2947
+ dossier = sub.add_parser("dossier", help="Building dossier")
2948
+ dsub = dossier.add_subparsers(dest="dossier_action", required=True)
2949
+ dsync = dsub.add_parser("sync", help="Sync registry")
2950
+ dsync.add_argument("--limit", type=int, default=1000)
2951
+ dsync.add_argument("--dataset", default="auto")
2952
+ dsync.set_defaults(func=_cmd_dossier)
2953
+ dmatch = dsub.add_parser("match", help="Match card")
2954
+ dmatch.add_argument("--file", default=None, help="Card JSON")
2955
+ dmatch.add_argument("--address", default=None, help="Address")
2956
+ dmatch.set_defaults(func=_cmd_dossier)
2957
+ dossier.set_defaults(func=_cmd_dossier)
2958
+
2959
+ # developer (F-17)
2960
+ developer = sub.add_parser("developer", help="Developer dossier")
2961
+ developer.add_argument("--name", default=None, help="Developer name")
2962
+ developer.add_argument("--inn", default=None, help="INN")
2963
+ developer.add_argument("--from-file", dest="from_file", default=None, help="Card JSON")
2964
+ developer.add_argument("--litcheck", action="store_true", help="Run litigation pre-check")
2965
+ developer.add_argument("--out", default=None, help="Output file")
2966
+ developer.add_argument("--output", default=None, help="Output file (alias)")
2967
+ developer.set_defaults(func=_cmd_developer)
2968
+
2969
+ # noise (F-16)
2970
+ noise = sub.add_parser("noise", help="Noise monitoring")
2971
+ nsub = noise.add_subparsers(dest="noise_action", required=False)
2972
+ nsync = nsub.add_parser("sync", help="Sync noise data")
2973
+ nsync.add_argument("--limit", type=int, default=1000)
2974
+ nsync.set_defaults(func=_cmd_noise, noise_action="sync")
2975
+ noise.add_argument("--file", default=None, help="Card JSON")
2976
+ noise.add_argument("--lat", type=float, default=None)
2977
+ noise.add_argument("--lon", type=float, default=None)
2978
+ noise.add_argument("--radius-m", type=int, default=1500, dest="radius_m")
2979
+ noise.add_argument("--out", default=None, help="Output file")
2980
+ noise.add_argument("--output", default=None, help="Output file (alias)")
2981
+ noise.set_defaults(func=_cmd_noise)
2982
+
2983
+ domclick = sub.add_parser("domclick", help="Domclick listings")
2984
+ dcsub = domclick.add_subparsers(dest="domclick_action", required=False)
2985
+ dcp = dcsub.add_parser("parse", help="Parse saved Domclick innerText")
2986
+ dcp.add_argument("--file", required=True)
2987
+ dcp.set_defaults(func=_cmd_domclick)
2988
+
2989
+ source = sub.add_parser("source", help="AGPL §13: license + source repository info")
2990
+ source.set_defaults(func=_cmd_source)
2991
+
2992
+ rush = sub.add_parser("rush", help="Transport accessibility + current traffic")
2993
+ rush.add_argument("--file", default=None, help="Card JSON")
2994
+ rush.add_argument("--hour", type=int, default=None,
2995
+ help="Почасовой web-прогноз: час 0-23 (только с NEDVIGA_EGRESS_PROXY)")
2996
+ rush.set_defaults(func=_cmd_rush)
2997
+
2998
+ deck = sub.add_parser("deck", help="PPTX deck from cards")
2999
+ deck.add_argument("--file", default=None)
3000
+ deck.add_argument("--project", default=None)
3001
+ deck.add_argument("--out", default=None)
3002
+ deck.add_argument("--title", default=None)
3003
+ deck.set_defaults(func=_cmd_deck)
3004
+
3005
+ # wizard (F-61)
3006
+ wizard = sub.add_parser("wizard", help="First-run wizard")
3007
+ wsub = wizard.add_subparsers(dest="wizard_action", required=True)
3008
+ winit = wsub.add_parser("init", help="Initialize (example: wizard init --profile agent --out ./out)")
3009
+ winit.add_argument("--profile", default="agent", choices=["agent", "family"])
3010
+ winit.add_argument("--out", default=None)
3011
+ winit.add_argument("--output", default=None)
3012
+ winit.set_defaults(func=_cmd_wizard)
3013
+ wdoc = wsub.add_parser("doctor", help="Diagnostics (example: wizard doctor --json)")
3014
+ wdoc.add_argument("--json", nargs="?", const="-", default=None)
3015
+ wdoc.add_argument("--out", default=None)
3016
+ wdoc.add_argument("--output", default=None)
3017
+ wdoc.set_defaults(func=_cmd_wizard)
3018
+ wizard.set_defaults(func=_cmd_wizard)
3019
+
3020
+ # avito (F-15)
3021
+ avito = sub.add_parser("avito", help="Avito adapter")
3022
+ asub = avito.add_subparsers(dest="avito_action", required=True)
3023
+ aparse = asub.add_parser("parse", help="Parse livetext")
3024
+ aparse.add_argument("--file", required=True, help="Livetext file")
3025
+ aparse.add_argument("--out", default=None, help="Output file")
3026
+ aparse.add_argument("--output", default=None, help="Output file (alias)")
3027
+ aparse.set_defaults(func=_cmd_avito)
3028
+ avito.set_defaults(func=_cmd_avito)
3029
+
3030
+ # doctor
3031
+ doctor = sub.add_parser("doctor", help="System diagnostics")
3032
+ doctor.add_argument("--verify-signature", default=None, dest="verify_signature")
3033
+ doctor.add_argument(
3034
+ "--json",
3035
+ nargs="?",
3036
+ const="-",
3037
+ default=None,
3038
+ help="Write JSON result to FILE; default stdout when flag present",
3039
+ )
3040
+ doctor.set_defaults(func=_cmd_doctor)
3041
+
3042
+ _annotate_examples(sub)
3043
+ return parser
3044
+
3045
+
3046
+ def main(argv: Sequence[str] | None = None) -> int:
3047
+ parser = _build_parser()
3048
+ args = parser.parse_args(argv)
3049
+ from .roles import current_role, validate_role, check_permission, role_allows_contacts
3050
+
3051
+ role = current_role()
3052
+ args.role = role
3053
+
3054
+ ok, reason = validate_role()
3055
+ if not ok:
3056
+ envelope = make_envelope(
3057
+ status="error", reason=reason or "unknown_role",
3058
+ data={"role": role},
3059
+ metadata={"engine": "roles", "cached": False, "elapsed_seconds": 0.0, "role": role},
3060
+ )
3061
+ _write_output(_dump_envelope(envelope, args), args)
3062
+ return 3
3063
+
3064
+ allowed, reason = check_permission(args)
3065
+ if not allowed:
3066
+ envelope = make_envelope(
3067
+ status="error", reason=reason or "role_forbidden",
3068
+ data={"role": role, "command": getattr(args, "command", None)},
3069
+ metadata={"engine": "roles", "cached": False, "elapsed_seconds": 0.0, "role": role},
3070
+ )
3071
+ _write_output(_dump_envelope(envelope, args), args)
3072
+ return 3
3073
+
3074
+ # purpose required for --with-contacts (agent only)
3075
+ if getattr(args, "with_contacts", False) and role == "agent":
3076
+ if not getattr(args, "purpose", None):
3077
+ envelope = make_envelope(
3078
+ status="error", reason="pii_purpose_required",
3079
+ data={"message": "--purpose is required with --with-contacts"},
3080
+ metadata={"engine": "roles", "cached": False, "elapsed_seconds": 0.0, "role": role},
3081
+ )
3082
+ _write_output(_dump_envelope(envelope, args), args)
3083
+ return 3
3084
+
3085
+ # role-aware contacts: non-agent cannot open contacts
3086
+ if getattr(args, "with_contacts", False) and not role_allows_contacts(role):
3087
+ args.with_contacts = False
3088
+ args._contacts_requested = True
3089
+
3090
+ rc = args.func(args)
3091
+
3092
+ # audit log (write commands + with-contacts), no recursion for audit/pii-log
3093
+ try:
3094
+ from .roles import is_write
3095
+ from .auditlog import log_action
3096
+ cmd = getattr(args, "command", None)
3097
+ should_log = is_write(args) or bool(getattr(args, "with_contacts", False))
3098
+ if should_log and cmd not in {"audit", "pii-log"}:
3099
+ card_id = (getattr(args, "card_id", None) or getattr(args, "target", None)
3100
+ or getattr(args, "a", None))
3101
+ log_action(
3102
+ role=role,
3103
+ command=cmd,
3104
+ card_id=card_id,
3105
+ rc=rc,
3106
+ reason=None,
3107
+ argv=[str(x) for x in (argv or [])],
3108
+ pii=bool(getattr(args, "with_contacts", False)),
3109
+ purpose=getattr(args, "purpose", None),
3110
+ )
3111
+ except Exception:
3112
+ pass
3113
+
3114
+ return rc
3115
+
3116
+
3117
+ if __name__ == "__main__": # pragma: no cover
3118
+ sys.exit(main())