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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- 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 @@
|
|
|
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"]}
|
sud_cli/efrsb_manual.py
ADDED
|
@@ -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
|
+
}
|
sud_cli/efrsb_public.py
ADDED
|
@@ -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)")
|