sud-cli 0.1.1__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 (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,99 @@
1
+ """Deadline matrix (control points) for a case (sud v2.7.0).
2
+
3
+ Builds every applicable point from the approved ``deadline_matrix`` (legal_rules)
4
+ without inventing base dates: a point without an anchored/assumed base event
5
+ gets ``due_at=None`` and ``needs_anchor=True`` (правило «тексты только из реестра»).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import date
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from .db import get_case, get_connection, get_latest_deadline_state, init_db
13
+ from .legal_deadlines import calculate_deadline
14
+ from .legal_engine import STATUS_OK, load_rules
15
+
16
+
17
+ INSTANCE_TO_JURISDICTION = {
18
+ "apk": "arbitration",
19
+ "gpk": "general_jurisdiction",
20
+ "kas": "kas",
21
+ }
22
+
23
+
24
+ def _basis_event_and_date(latest: Optional[dict]) -> Tuple[str, Optional[date]]:
25
+ if not latest:
26
+ return "MISSING", None
27
+ basis = latest.get("basis")
28
+ if not isinstance(basis, dict):
29
+ return "MISSING", None
30
+ raw = basis.get("event_date")
31
+ if not raw:
32
+ return "MISSING", None
33
+ try:
34
+ d = date.fromisoformat(str(raw)[:10])
35
+ except ValueError:
36
+ return "MISSING", None
37
+ if "method" in basis:
38
+ return "anchored", d
39
+ if basis.get("assumed") is True:
40
+ return "assumed", d
41
+ return "anchored", d
42
+
43
+
44
+ def build_matrix(
45
+ state_dir: Any,
46
+ case_id: str,
47
+ instance_override: Optional[str] = None,
48
+ rules: Optional[dict] = None,
49
+ as_of: Optional[str] = None,
50
+ ) -> Tuple[str, List[dict]]:
51
+ rules = rules or load_rules()
52
+ init_db(state_dir)
53
+ conn = get_connection(state_dir)
54
+ try:
55
+ case = get_case(conn, case_id)
56
+ if case is None:
57
+ raise ValueError(f"case not found: {case_id}")
58
+ instance = (instance_override or case.get("instance") or "unknown")
59
+ instance = str(instance).strip().lower()
60
+ if instance not in ("apk", "gpk", "kas", "unknown"):
61
+ raise ValueError(f"invalid instance: {instance}")
62
+
63
+ matrix = rules.get("deadline_matrix", {}) or {}
64
+ if instance in INSTANCE_TO_JURISDICTION:
65
+ target = INSTANCE_TO_JURISDICTION[instance]
66
+ keys = [k for k, v in matrix.items() if (v or {}).get("jurisdiction") == target]
67
+ else:
68
+ keys = list(matrix.keys())
69
+
70
+ points: List[dict] = []
71
+ for key in keys:
72
+ entry = matrix.get(key) or {}
73
+ latest = get_latest_deadline_state(conn, case_id, key)
74
+ state_val = latest.get("state") if latest else None
75
+ base_event, base_date = _basis_event_and_date(latest)
76
+
77
+ due_at = None
78
+ if base_date is not None:
79
+ try:
80
+ res = calculate_deadline(deadline_key=key, event_date=base_date, rules=rules)
81
+ if res.status == STATUS_OK and getattr(res, "deadline", None) is not None:
82
+ due_at = res.deadline.isoformat()
83
+ except Exception:
84
+ due_at = None
85
+
86
+ point = {
87
+ "key": key,
88
+ "norm": entry.get("legal_basis"),
89
+ "base_event": base_event,
90
+ "due_at": due_at,
91
+ "needs_anchor": base_date is None,
92
+ "state": state_val,
93
+ }
94
+ if base_date is None:
95
+ point["warning_basis"] = "event_date_missing"
96
+ points.append(point)
97
+ return instance, points
98
+ finally:
99
+ conn.close()
@@ -0,0 +1,25 @@
1
+ from .db import (
2
+ anchor_deadline_state,
3
+ append_deadline_audit,
4
+ confirm_deadline_state,
5
+ get_latest_anchored_state,
6
+ get_latest_deadline_state,
7
+ insert_deadline_state,
8
+ list_all_deadline_states,
9
+ list_deadline_states,
10
+ read_deadline_audit,
11
+ waive_deadline_state,
12
+ )
13
+
14
+ __all__ = [
15
+ "anchor_deadline_state",
16
+ "append_deadline_audit",
17
+ "confirm_deadline_state",
18
+ "get_latest_anchored_state",
19
+ "get_latest_deadline_state",
20
+ "insert_deadline_state",
21
+ "list_all_deadline_states",
22
+ "list_deadline_states",
23
+ "read_deadline_audit",
24
+ "waive_deadline_state",
25
+ ]
sud_cli/deadlines.py ADDED
@@ -0,0 +1,8 @@
1
+ from .db import (
2
+ anchor_deadline_state,
3
+ confirm_deadline_state,
4
+ list_all_deadline_states,
5
+ list_deadline_states,
6
+ read_deadline_audit,
7
+ waive_deadline_state,
8
+ )
@@ -0,0 +1 @@
1
+ from .deadline_state import *
sud_cli/deferral.py ADDED
@@ -0,0 +1,5 @@
1
+ """Template for a state-fee deferral motion."""
2
+
3
+
4
+ def generate_deferral(applicant, court, amount, reason):
5
+ return {"status": "ok", "text": f"В {court}\n\nХОДАТАЙСТВО об отсрочке уплаты государственной пошлины\n\n{applicant} просит предоставить отсрочку уплаты государственной пошлины в размере {amount} руб. на основании статьи 333.41 НК РФ и статьи 102 АПК РФ. В связи с {reason} немедленная уплата пошлины затруднительна. Согласно пункту 4 Постановления Пленума ВАС РФ № 20 суд вправе учесть имущественное положение заявителя.\n\nПриложения:\n1. Справка ИФНС об открытых банковских счетах.\n2. Справки банков об остатках денежных средств.\n3. Справки банков об отсутствии картотеки № 2 (неисполненных расчетных документов).", "legal_basis": ["ст. 333.41 НК РФ", "ст. 102 АПК РФ", "п. 4 Постановления Пленума ВАС РФ № 20"]}
@@ -0,0 +1,85 @@
1
+ """Ручной контур ЕФРСБ (act 25.10 §1.2): human-in-the-loop fallback.
2
+
3
+ Боевой автоматизированный путь проверки банкротства — КАД (kad-search).
4
+ ЕФРСБ закрыт за Qrator/WAF; автономный browser-адаптер запрещён актом (ст. 272 УК РФ).
5
+ Модуль не делает сетевых запросов: формирует валидную поисковую ссылку, которую
6
+ ОТКРЫВАЕТ ЧЕЛОВЕК в основном браузере, и интерфейс фиксации provenance в досье дела.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ BASE_URL = "https://bankrot.fedresurs.ru/"
13
+ _METHOD = "efrsb_human_verified" # не сеть: человек + запись реквизитов
14
+
15
+ KINDS = ("ul", "ip", "fl")
16
+
17
+
18
+ class EfrsbManualError(ValueError):
19
+ pass
20
+
21
+
22
+ def _require(cond: bool, msg: str) -> None:
23
+ if not cond:
24
+ raise EfrsbManualError(msg)
25
+
26
+
27
+ def link_plan(kind: str, *, name: Optional[str] = None, inn: Optional[str] = None,
28
+ ogrn: Optional[str] = None, birth_date: Optional[str] = None) -> dict:
29
+ """Собрать план ручной проверки ЕФРСБ (без сетевых запросов, акт 25.10)."""
30
+ kind = (kind or "").lower()
31
+ _require(kind in KINDS, f"kind должен быть одним из {KINDS}")
32
+ if kind == "fl":
33
+ _require(bool(name), "для физлица нужно ФИО (--name)")
34
+ query = name
35
+ params = {"debtorFirstName": name}
36
+ elif kind == "ip":
37
+ _require(bool(name or inn), "для ИП нужно имя или ИНН")
38
+ query = inn or name
39
+ params = {"inn": inn} if inn else {"debtorLastName": name}
40
+ else:
41
+ _require(bool(name or inn or ogrn), "для ЮЛ нужно имя/ИНН/ОГРН")
42
+ query = inn or ogrn or name
43
+ params = {"inn": inn} if inn else ({"ogrn": ogrn} if ogrn else {"name": name})
44
+
45
+ # Предзаполнение: официальный интерфейс поиска принимает строковый запрос;
46
+ # ссылка остаётся на главную — человек вводит запрос в защищённой форме (Qrator).
47
+ steps = [
48
+ f"Открой в основном браузере: {BASE_URL}",
49
+ f"Раздел «Поиск» → должник: {query}",
50
+ "Пройди капчу/проверку — это делает ТОЛЬКО человек",
51
+ "Сохрани карточку должника/публикации (номер, дата, вид сообщения)",
52
+ "Зафиксируй в досье: sud-cli efrsb record --case <ID> --subject ... --result ... --checked-by <кто>",
53
+ ]
54
+ return {
55
+ "kind": kind,
56
+ "base_url": BASE_URL,
57
+ "query": query,
58
+ "search_params": {k: v for k, v in params.items() if v},
59
+ "steps": steps,
60
+ "automation": "off (act 25.10: browser-адаптер запрещён; КАД — боевой путь, ЕФРСБ — ручная перепроверка)",
61
+ "primary_instead": "kad-search (дела о несостоятельности, категория Б) — автоматизировано",
62
+ }
63
+
64
+
65
+ def record_payload(*, subject: str, result: str, checked_by: str,
66
+ source_ref: Optional[str] = None,
67
+ notes: Optional[str] = None) -> dict:
68
+ """Provenance-запись результата ручной проверки ЕФРСБ (аналог fssp record)."""
69
+ _require(bool(subject), "нужен --subject (ИНН/ФИО/дело)")
70
+ _require(result in ("found", "none", "inconclusive"),
71
+ "result должен быть found|none|inconclusive")
72
+ _require(bool(checked_by), "нужен --checked-by (кто проверял)")
73
+ note_bits = [f"method={_METHOD}"]
74
+ if source_ref:
75
+ note_bits.append(f"source_ref={source_ref}")
76
+ if notes:
77
+ note_bits.append(notes)
78
+ return {
79
+ "subject": subject,
80
+ "result": result,
81
+ "title": f"ЕФРСБ вручную: {subject} — {result}",
82
+ "note": " ".join(note_bits),
83
+ "checked_by": checked_by,
84
+ "source": "bankrot.fedresurs.ru (человек)",
85
+ }
@@ -0,0 +1,210 @@
1
+ """ЕФРСБ (bankrot.fedresurs.ru) — банкротные дела ЮЛ, ИП и граждан.
2
+
3
+ v0.1.0 (2026-10-20, мандат владельца: «из ru-legal берём лучшее в sud-cli»).
4
+ Паттерн заимствован у AlsKozlov/ru-legal efrsb-коннектора (Apache-2.0) в
5
+ clean-room стиле: их API-карта, наш транспорт (sud_netguard fail-closed),
6
+ своя нормализация. Их коннектор не импортируется.
7
+
8
+ Их задокументированные уроки, воспринятые здесь:
9
+ - custom User-Agent блокируется (HTTP 403) -> нужен обычный браузерный UA;
10
+ - Referer обязателен;
11
+ - не-JSON ответ => источник в degraded mode (не «данных нет»).
12
+
13
+ API (public, без ключей):
14
+ GET /backend/companies ?searchString=&limit=
15
+ GET /backend/persons ?searchString=&birthDate=&limit=
16
+ GET /backend/cases/<id>/publications?limit=
17
+ GET /backend/arbitrManagers?searchString=&inn=&sroName=
18
+ Ответ: {"pageData": [...], ...}
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Any, Optional
24
+ from urllib.request import Request as _Request
25
+
26
+ EFRSB_BASE = "https://bankrot.fedresurs.ru"
27
+ EFRSB_API_BASE = f"{EFRSB_BASE}/backend"
28
+
29
+ #: обычный браузерный UA: custom UA (см. ru-legal разведку) даёт 403
30
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
31
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
32
+
33
+
34
+ class EfrsbPublicError(Exception):
35
+ """Ошибка транспорта/ответа ЕФРСБ (fail-closed, без тихих деградаций)."""
36
+
37
+
38
+ def _efrsb_http_get(url: str, *, timeout: int = 30) -> str:
39
+ """GET с netguard-защитой: egress fail-closed + per-host breaker."""
40
+ from sud_cli.sud_netguard import (
41
+ BreakerOpenError,
42
+ breaker_trip,
43
+ check_before_fetch,
44
+ )
45
+
46
+ try:
47
+ check_before_fetch(url)
48
+ except BreakerOpenError as exc:
49
+ raise EfrsbPublicError(
50
+ f"host in cooldown (anti-ban breaker): {exc}") from exc
51
+
52
+ import urllib.error as _ue
53
+ import urllib.request as _ur
54
+
55
+ req = _Request(url, headers={
56
+ "User-Agent": _UA,
57
+ "Accept": "application/json",
58
+ "Referer": f"{EFRSB_BASE}/",
59
+ })
60
+ class _NoRedirect(_ur.HTTPRedirectHandler):
61
+ # v2.3.3 (внешний review): efrsb обязан НЕ следовать редиректам —
62
+ # 302 от источника обходил DNS-pin netguard (redirect к внутренним хостам).
63
+ def redirect_request(self, *a, **k):
64
+ return None
65
+
66
+ try:
67
+ with _ur.build_opener(_NoRedirect()).open(req, timeout=timeout) as resp:
68
+ raw = resp.read(5_000_000) # size-cap: WAF-страницы/бомбы не съедают память
69
+ except _ue.HTTPError as exc:
70
+ if exc.code in (301, 302, 303, 307, 308, 403, 429):
71
+ breaker_trip(url, reason=f"efrsb_http_{exc.code}")
72
+ # PII: searchString (ФИО/ИНН) был в тексте ошибки -> stdout/логи. Только host+path.
73
+ from urllib.parse import urlsplit
74
+ loc = urlsplit(url)
75
+ raise EfrsbPublicError(f"efrsb http {exc.code}: {loc.netloc}{loc.path}") from exc
76
+ except _ue.URLError as exc:
77
+ raise EfrsbPublicError("efrsb unreachable: " + str(exc.reason)[:80]) from exc
78
+ try:
79
+ return raw.decode("utf-8")
80
+ except UnicodeDecodeError:
81
+ return raw.decode("cp1251", errors="replace")
82
+
83
+
84
+ def _api_get(path: str, params: dict[str, Any], timeout: int = 30) -> dict:
85
+ from urllib.parse import urlencode
86
+
87
+ qs = urlencode({k: v for k, v in params.items() if v is not None})
88
+ url = f"{EFRSB_API_BASE}{path}{'?' + qs if qs else ''}"
89
+ body = _efrsb_http_get(url, timeout=timeout)
90
+ try:
91
+ data = json.loads(body)
92
+ except json.JSONDecodeError as exc:
93
+ # их урок: не-JSON = degraded источник; для агента это error, не пустота
94
+ raise EfrsbPublicError("efrsb returned non-JSON (degraded source)") from exc
95
+ if not isinstance(data, dict):
96
+ raise EfrsbPublicError("efrsb returned unexpected payload type")
97
+ return data
98
+
99
+
100
+ def _page_data(data: dict) -> list[dict]:
101
+ # v2.3.2 (sa-1 F-07): JSON-ответ источника без pageData (WAF/QRATOR-обёртка,
102
+ # {"pageData": null}) — это деградация источника, а не «данных нет».
103
+ if "pageData" not in data or data["pageData"] is None:
104
+ raise EfrsbPublicError("efrsb payload without pageData (degraded source)")
105
+ items = data["pageData"]
106
+ return [i for i in items if isinstance(i, dict)] if isinstance(items, list) else []
107
+
108
+
109
+ def _row_url(item: dict, kind: str) -> Optional[str]:
110
+ rid = item.get("id")
111
+ return f"{EFRSB_BASE}/{kind}/{rid}" if rid else None
112
+
113
+
114
+ def _normalize(item: dict) -> dict:
115
+ return {
116
+ "id": item.get("id"),
117
+ "name": item.get("name") or item.get("fullName") or item.get("lastName"),
118
+ "inn": item.get("inn"),
119
+ "ogrn": item.get("ogrn"),
120
+ "category": item.get("category"),
121
+ "bankrupt_type": item.get("type") or item.get("bankruptType"),
122
+ "case_number": item.get("caseNumber"),
123
+ "date": item.get("date") or item.get("publishDate"),
124
+ "url": _row_url(item, "bankrupt/id") if item.get("id") else None,
125
+ "raw_keys": sorted(k for k in item if k not in ("id", "name", "inn", "ogrn")),
126
+ }
127
+
128
+
129
+ def _validate_search_args(**kwargs: Any) -> dict:
130
+ clean = {k: v for k, v in kwargs.items() if v}
131
+ if not clean:
132
+ raise EfrsbPublicError("нужен хотя бы один поисковый параметр")
133
+ if len(clean) > 1:
134
+ # v2.3.3 (внешний review): API принимает один searchString — «inn or ogrn or name»
135
+ # молча выбрасывал переданные остальные параметры. Fail-closed: просим один.
136
+ raise EfrsbPublicError("параметры inn/ogrn/name взаимоисключающие: передайте ровно один")
137
+ return clean
138
+
139
+
140
+ def search_companies(inn: str | None = None, ogrn: str | None = None,
141
+ name: str | None = None, limit: int = 5) -> dict:
142
+ """Банкротный поиск ЮЛ/ИП. INN(10)/ИП INN(12)/OGRN(13|15)/name>=3."""
143
+ args = _validate_search_args(inn=inn, ogrn=ogrn, name=name)
144
+ if inn and not (inn.isdigit() and len(inn) in (10, 12)):
145
+ raise EfrsbPublicError("ИНН должен быть 10 (ЮЛ) или 12 (ИП) цифр")
146
+ if ogrn and not (ogrn.isdigit() and len(ogrn) in (13, 15)):
147
+ raise EfrsbPublicError("ОГРН должен быть 13 (ЮЛ) или 15 (ИП) цифр")
148
+ if name and len(name) < 3:
149
+ raise EfrsbPublicError("name: минимум 3 символа")
150
+ search = inn or ogrn or name
151
+ data = _api_get("/companies", {"searchString": search, "limit": limit})
152
+ rows = _page_data(data)
153
+ return {"status": "ok" if rows else "not_found",
154
+ "query": search, "count": len(rows),
155
+ "results": [_normalize(r) for r in rows[:limit]]}
156
+
157
+
158
+ def search_persons(full_name: str, birth_date: str | None = None,
159
+ limit: int = 5) -> dict:
160
+ """Банкротный поиск физлиц по ФИО; birthDate (YYYY-MM-DD) сужает выборку."""
161
+ if len(full_name) < 3:
162
+ raise EfrsbPublicError("full_name: минимум 3 символа")
163
+ params: dict[str, Any] = {"searchString": full_name, "limit": limit}
164
+ if birth_date:
165
+ params["birthDate"] = birth_date
166
+ data = _api_get("/persons", params)
167
+ rows = _page_data(data)
168
+ return {"status": "ok" if rows else "not_found",
169
+ "query": full_name, "count": len(rows),
170
+ "results": [_normalize(r) for r in rows[:limit]]}
171
+
172
+
173
+ def case_publications(case_id: str, limit: int = 10) -> dict:
174
+ """Публикации по делу (для слежения за конкретным банкротством)."""
175
+ if not case_id or "/" in case_id or ".." in case_id:
176
+ raise EfrsbPublicError("case_id: ожидается идентификатор дела ЕФРСБ")
177
+ data = _api_get(f"/cases/{case_id}/publications", {"limit": limit})
178
+ rows = _page_data(data)
179
+ return {"status": "ok" if rows else "not_found", "case_id": case_id,
180
+ "count": len(rows),
181
+ "results": [{
182
+ "id": r.get("id"),
183
+ "title": r.get("title"),
184
+ "date": r.get("date") or r.get("publishDate"),
185
+ "url": _row_url(r, "message"),
186
+ } for r in rows[:limit]]}
187
+
188
+
189
+ def search_managers(search: str | None = None, inn: str | None = None,
190
+ sro: str | None = None, limit: int = 5) -> dict:
191
+ """Арбитражные управляющие (поиск по ФИО / ИНН / СРО)."""
192
+ args = _validate_search_args(search=search, inn=inn, sro=sro)
193
+ params: dict[str, Any] = {"limit": limit}
194
+ if search:
195
+ params["searchString"] = search
196
+ if inn:
197
+ params["inn"] = inn
198
+ if sro:
199
+ params["sroName"] = sro
200
+ data = _api_get("/arbitrManagers", params)
201
+ rows = _page_data(data)
202
+ return {"status": "ok" if rows else "not_found",
203
+ "query": args, "count": len(rows),
204
+ "results": [{
205
+ "id": r.get("id"),
206
+ "name": r.get("name") or r.get("fullName"),
207
+ "inn": r.get("inn"),
208
+ "sro": r.get("sroName") or r.get("sro"),
209
+ "url": _row_url(r, "arbitrManager/id") if r.get("id") else None,
210
+ } for r in rows[:limit]]}
sud_cli/egrul_nalog.py ADDED
@@ -0,0 +1,150 @@
1
+ """egrul.nalog.ru — открытая проверка контрагента из ЕГРЮЛ/ЕГРИП (ФНС России).
2
+
3
+ Контракт живой разведки 2026-10-25 (RU-egress, проверено на реальных записях):
4
+
5
+ POST https://egrul.nalog.ru/
6
+ form: query=<ИНН|ОГРН|название>, nameEq=, page=, vyp3CaptchaToken=
7
+ -> JSON {"t": "<токен>", "captchaRequired": false}
8
+ GET https://egrul.nalog.ru/search-result/<t>?r=<anti-cache>
9
+ -> JSON {"rows": [ {...}, ... ]} (пустой/не-JSON ответ = ещё готовится)
10
+
11
+ Поля строки: c=краткое наименование, n=полное наименование, o=ОГРН, i=ИНН,
12
+ k=тип (ul/ип), g=орган управления (ФИО), p=ОКПО, r=дата регистрации, rn=регион.
13
+
14
+ Сведения ЕГРЮЛ/ЕГРИП — открытые данные ФНС (ст. 6 129-ФЗ, акты государственной
15
+ регистрации общедоступны). Выписка vyp3 (PDF/XML) намеренно НЕ реализована:
16
+ требует капчу и не нужна для карточки проверки.
17
+
18
+ Сетевой слой: netguard (fail-closed egress, DNS-pin, per-host breaker),
19
+ редиректы запрещены, размер ответа ограничен, строки ошибок без PII.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import random
25
+ import time
26
+ from typing import Any, Optional
27
+ from urllib.parse import urlencode
28
+ from urllib.request import Request as _Request
29
+
30
+ EGRUL_BASE = "https://egrul.nalog.ru"
31
+
32
+ #: обычный браузерный UA: сервер отдаёт JSON только непустому UA (разведка 25.10)
33
+ _UA = ("Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/126.0")
34
+
35
+ _MAX_ATTEMPTS = 4
36
+ _BACKOFF_S = (1.2, 2.0, 3.0)
37
+
38
+
39
+ class EgrulError(Exception):
40
+ """Надёжное сообщение об ошибке без PII (запрос токена может содержать ИНН/ФИО)."""
41
+
42
+
43
+ def _http(method: str, url: str, *, body: Optional[str] = None, timeout: int = 25) -> str:
44
+ """Один защищённый HTTP-обмен: netguard + no-redirect + size cap."""
45
+ from sud_cli.sud_netguard import (
46
+ BreakerOpenError,
47
+ breaker_trip,
48
+ check_before_fetch,
49
+ )
50
+
51
+ try:
52
+ check_before_fetch(url)
53
+ except BreakerOpenError as exc:
54
+ raise EgrulError(f"host in cooldown (anti-ban breaker): {exc}") from exc
55
+
56
+ import urllib.error as _ue
57
+ import urllib.request as _ur
58
+
59
+ headers = {
60
+ "User-Agent": _UA,
61
+ "Referer": f"{EGRUL_BASE}/",
62
+ "Accept": "application/json, text/html;q=0.8",
63
+ }
64
+ data = body.encode("utf-8") if body is not None else None
65
+ if data is not None:
66
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
67
+ req = _Request(url, data=data, headers=headers, method=method)
68
+
69
+ class _NoRedirect(_ur.HTTPRedirectHandler):
70
+ # редирект от источника обходил бы DNS-pin netguard (см. efrsb v2.3.3)
71
+ def redirect_request(self, *a, **k):
72
+ return None
73
+
74
+ try:
75
+ with _ur.build_opener(_NoRedirect()).open(req, timeout=timeout) as resp:
76
+ raw = resp.read(2_000_000)
77
+ except _ue.HTTPError as exc:
78
+ if exc.code in (301, 302, 303, 307, 308, 403, 429):
79
+ breaker_trip(url, reason=f"egrul_http_{exc.code}")
80
+ from urllib.parse import urlsplit
81
+ loc = urlsplit(url)
82
+ raise EgrulError(f"egrul http {exc.code}: {loc.netloc}{loc.path}") from exc
83
+ except _ue.URLError as exc:
84
+ raise EgrulError("egrul unreachable: " + str(exc.reason)[:80]) from exc
85
+ text = raw.decode("utf-8", errors="replace")
86
+ return text
87
+
88
+
89
+ def _post_query(query: str, *, exact_name: bool = False) -> str:
90
+ body = urlencode({
91
+ "query": query,
92
+ "nameEq": "on" if exact_name else "",
93
+ "page": "",
94
+ "vyp3CaptchaToken": "",
95
+ })
96
+ text = _http("POST", f"{EGRUL_BASE}/", body=body)
97
+ try:
98
+ data = json.loads(text)
99
+ except json.JSONDecodeError as exc:
100
+ raise EgrulError("egrul: unexpected answer to query (WAF or captcha wall)") from exc
101
+ token = data.get("t")
102
+ if not token or not isinstance(token, str):
103
+ raise EgrulError("egrul: query rejected (no search token)")
104
+ if data.get("captchaRequired"):
105
+ raise EgrulError("egrul: captcha required, retry later")
106
+ return token
107
+
108
+
109
+ def _norm_row(row: dict[str, Any]) -> dict[str, Any]:
110
+ kind = {"ul": "юридическое лицо", "ip": "индивидуальный предприниматель"}.get(
111
+ str(row.get("k")), row.get("k"))
112
+ return {
113
+ "short_name": row.get("c"),
114
+ "full_name": row.get("n"),
115
+ "ogrn": row.get("o"),
116
+ "inn": row.get("i"),
117
+ "kind": kind,
118
+ "governing_body": row.get("g"),
119
+ "okpo": row.get("p"),
120
+ "registration_date": row.get("r"),
121
+ "region": row.get("rn"),
122
+ }
123
+
124
+
125
+ def search(query: str, *, exact_name: bool = False, timeout: int = 25) -> dict[str, Any]:
126
+ """Найти организацию/ИП в ЕГРЮЛ/ЕГРИП по ИНН, ОГРН или наименованию.
127
+
128
+ Возвращает {"query":..., "rows": [карточки...]} — только открытые сведения ФНС.
129
+ """
130
+ if not query or not str(query).strip():
131
+ raise EgrulError("egrul: empty query")
132
+ token = _post_query(str(query).strip(), exact_name=exact_name)
133
+ last_text = ""
134
+ for attempt in range(_MAX_ATTEMPTS):
135
+ url = f"{EGRUL_BASE}/search-result/{token}?r={random.randint(10**8, 10**9 - 1)}"
136
+ last_text = _http("GET", url, timeout=timeout)
137
+ try:
138
+ data = json.loads(last_text)
139
+ except json.JSONDecodeError:
140
+ data = None
141
+ if isinstance(data, dict) and "rows" in data:
142
+ return {
143
+ "query": str(query).strip(),
144
+ "rows": [_norm_row(r) for r in data.get("rows") or []],
145
+ "source": EGRUL_BASE,
146
+ "provider_label": "egrul.nalog.ru (открытые сведения ФНС)",
147
+ }
148
+ if attempt < _MAX_ATTEMPTS - 1:
149
+ time.sleep(_BACKOFF_S[attempt])
150
+ raise EgrulError("egrul: search result not ready (retry later)")