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
sud_cli/fssp_semi.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""S-61 полу-автомат ФССП (решение проекта 25.10, юридическая проверка запрошена отдельно).
|
|
2
|
+
|
|
3
|
+
Полу-автомат: скрипт делает ВСЁ, кроме капчи — открывает страницу БДИП через
|
|
4
|
+
общий loopback-браузер (Camofox runtime), заполняет форму, нажимает «Найти».
|
|
5
|
+
Если сайт выставил капчу — делает скриншот, отдаёт его человеку; человек
|
|
6
|
+
сообщает код, `fssp assist --resume` вводит его и забирает результат.
|
|
7
|
+
Капчу решает человек, не сервис-Recognizer и не брутфорс.
|
|
8
|
+
|
|
9
|
+
Ограничения честности:
|
|
10
|
+
- селекторы эвристические (страница недоступна для калибровки из-за лимита
|
|
11
|
+
подключений ФССП к нашему exit IP); при нераспознанной странице — статус
|
|
12
|
+
page_changed со снимком, ручной контур (fssp check) остаётся запасным;
|
|
13
|
+
- rate-limit: не более одного start на сессию; 503/429/'лимит подключений'
|
|
14
|
+
-> breaker_trip(fssp.gov.ru) и честная ошибка limited;
|
|
15
|
+
- runtime обязан быть loopback (SSRF-политика, как в мосте case-search).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
|
|
28
|
+
FSSP_URL = "https://fssp.gov.ru/iss/ip/"
|
|
29
|
+
DEFAULT_RUNTIME = "http://127.0.0.1:9377"
|
|
30
|
+
_LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
|
31
|
+
_TTL_S = 30 * 60
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FsspAssistError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _runtime_url() -> str:
|
|
39
|
+
raw = os.environ.get("CSE_FSSP_RUNTIME", DEFAULT_RUNTIME).strip()
|
|
40
|
+
parts = urlsplit(raw)
|
|
41
|
+
if parts.scheme not in ("http", "https") or parts.hostname not in _LOOPBACK:
|
|
42
|
+
raise FsspAssistError("CSE_FSSP_RUNTIME must be a loopback http(s) URL")
|
|
43
|
+
return raw.rstrip("/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _api(method: str, path: str, *, body: Optional[dict] = None,
|
|
47
|
+
query: Optional[dict] = None, timeout: int = 60, binary: bool = False):
|
|
48
|
+
import urllib.error
|
|
49
|
+
import urllib.request
|
|
50
|
+
|
|
51
|
+
base = _runtime_url()
|
|
52
|
+
qs = ("?" + "&".join(f"{k}={v}" for k, v in (query or {}).items())) if query else ""
|
|
53
|
+
url = f"{base}{path}{qs}"
|
|
54
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
55
|
+
req = urllib.request.Request(url, data=data, method=method,
|
|
56
|
+
headers={"Content-Type": "application/json"})
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
59
|
+
raw = resp.read(12_000_000)
|
|
60
|
+
except urllib.error.HTTPError as exc:
|
|
61
|
+
detail = exc.read(300).decode("utf-8", "replace") if binary is False else ""
|
|
62
|
+
raise FsspAssistError(f"runtime http {exc.code}: {detail[:160]}") from exc
|
|
63
|
+
except urllib.error.URLError as exc:
|
|
64
|
+
raise FsspAssistError(f"runtime unreachable: {str(exc.reason)[:80]}") from exc
|
|
65
|
+
if binary:
|
|
66
|
+
return raw
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(raw.decode("utf-8", "replace") or "{}")
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return {"raw": raw.decode("utf-8", "replace")[:400]}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _state_dir(state_dir: Path) -> Path:
|
|
74
|
+
d = Path(state_dir) / "fssp_assist"
|
|
75
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save(sess: dict) -> None:
|
|
80
|
+
d = _state_dir(Path(sess["state_dir"]))
|
|
81
|
+
(d / f"{sess['session_id']}.json").write_text(
|
|
82
|
+
json.dumps(sess, ensure_ascii=False), encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load(session_id: str, state_dir: Path) -> dict:
|
|
86
|
+
p = _state_dir(state_dir) / f"{session_id}.json"
|
|
87
|
+
if not p.is_file():
|
|
88
|
+
raise FsspAssistError(f"session not found: {session_id}")
|
|
89
|
+
sess = json.loads(p.read_text(encoding="utf-8"))
|
|
90
|
+
if time.time() - sess.get("created_at", 0) > _TTL_S:
|
|
91
|
+
raise FsspAssistError("session expired (30 min); start fresh or use manual contour")
|
|
92
|
+
return sess
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _breaker_trip(url: str) -> None:
|
|
96
|
+
try:
|
|
97
|
+
from sud_cli.sud_netguard import breaker_trip
|
|
98
|
+
breaker_trip(url, reason="fssp_assist_limited")
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_LIMIT_RE = re.compile(r"превысили лимит|попробуйте позже", re.I)
|
|
104
|
+
_CAPTCHA_RE = re.compile(r"captcha|капч|смарт-?каптча|не робот", re.I)
|
|
105
|
+
_RESULT_WORDS = re.compile(r"найдено|результат|исполнительное производство|отсутству", re.I)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_FORM_MARK = re.compile(r"Наименование предприятия-должника|Фамилия", re.I)
|
|
109
|
+
_RESULT_MARK = re.compile(
|
|
110
|
+
r"исполнительн\w* производств\w* (?:не )?(?:найдено|отсутству)"
|
|
111
|
+
r"|найдено (?:исполнительных )?производств"
|
|
112
|
+
r"|^.*\bдолжник\b", re.I | re.M)
|
|
113
|
+
_CAPTCHA_MARK = re.compile(r"captcha|каптч|не робот|robot check|challenge|подтвердите,? что", re.I)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detect_page(text: str, *, pre: bool = False) -> str:
|
|
117
|
+
body = text or ""
|
|
118
|
+
if _LIMIT_RE.search(body):
|
|
119
|
+
return "limited"
|
|
120
|
+
if _CAPTCHA_MARK.search(body):
|
|
121
|
+
return "captcha"
|
|
122
|
+
if not pre and _RESULT_MARK.search(body):
|
|
123
|
+
return "result"
|
|
124
|
+
if _FORM_MARK.search(body):
|
|
125
|
+
return "form"
|
|
126
|
+
return "unknown"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _close_tab(user_id: str, tab_id: str) -> None:
|
|
130
|
+
"""Закрыть вкладку (пул не должен копить мёртвые сессии)."""
|
|
131
|
+
try:
|
|
132
|
+
_api("DELETE", f"/tabs/{tab_id}", query={"userId": user_id})
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def start(state_dir: Path, *, kind: str, name: Optional[str] = None,
|
|
138
|
+
inn: Optional[str] = None, birth_date: Optional[str] = None) -> dict:
|
|
139
|
+
"""Открыть БДИП и заполнить форму; капчу — не решает."""
|
|
140
|
+
kind = (kind or "").strip().lower()
|
|
141
|
+
if kind not in ("fiz", "ur"):
|
|
142
|
+
raise FsspAssistError("kind must be fiz|ur")
|
|
143
|
+
if not (name or inn):
|
|
144
|
+
raise FsspAssistError("provide --name or --inn")
|
|
145
|
+
sid = secrets.token_hex(6)
|
|
146
|
+
user_id = f"sud-fssp-{sid}"
|
|
147
|
+
tab = _api("POST", "/tabs", body={"userId": user_id, "sessionKey": sid, "url": FSSP_URL})
|
|
148
|
+
tab_id = tab.get("tabId") or (tab.get("tab") or {}).get("tabId")
|
|
149
|
+
if not tab_id:
|
|
150
|
+
raise FsspAssistError(f"runtime did not return tabId: {str(tab)[:120]}")
|
|
151
|
+
_api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 15000})
|
|
152
|
+
snap = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id}, timeout=60)
|
|
153
|
+
text = snap.get("snapshot") or snap.get("text") or snap.get("raw") or json.dumps(snap, ensure_ascii=False)
|
|
154
|
+
state = _detect_page(text, pre=True)
|
|
155
|
+
sess = {"session_id": sid, "tab_id": tab_id, "user_id": user_id,
|
|
156
|
+
"state_dir": str(state_dir), "kind": kind, "name": name, "inn": inn,
|
|
157
|
+
"birth_date": birth_date, "created_at": time.time(), "page_state": state}
|
|
158
|
+
if state == "limited":
|
|
159
|
+
_breaker_trip(FSSP_URL)
|
|
160
|
+
sess["status"] = "limited"
|
|
161
|
+
sess["hint"] = ("ФССП ограничила подключения с exit IP; хост уведён в cooldown. "
|
|
162
|
+
"Повторите позже или используйте ручной контур: fssp check.")
|
|
163
|
+
_close_tab(user_id, tab_id)
|
|
164
|
+
_save(sess)
|
|
165
|
+
return sess
|
|
166
|
+
if state in ("form", "unknown"):
|
|
167
|
+
try:
|
|
168
|
+
filled = _fill_form(user_id, tab_id, kind=kind, name=name, inn=inn,
|
|
169
|
+
birth_date=birth_date)
|
|
170
|
+
sess["fill"] = filled
|
|
171
|
+
if filled.get("submitted"):
|
|
172
|
+
_api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 15000})
|
|
173
|
+
snap2 = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id})
|
|
174
|
+
t2 = snap2.get("snapshot") or snap2.get("text") or ""
|
|
175
|
+
state = _detect_page(t2, pre=False)
|
|
176
|
+
sess["page_state"] = state
|
|
177
|
+
sess["page_text"] = t2[:4000]
|
|
178
|
+
except FsspAssistError as exc:
|
|
179
|
+
sess["page_state"] = "page_changed"
|
|
180
|
+
sess["hint"] = f"автозаполнение не удалось: {exc}; используйте fssp check"
|
|
181
|
+
_close_tab(user_id, tab_id)
|
|
182
|
+
if sess["page_state"] == "captcha":
|
|
183
|
+
png = _state_dir(Path(state_dir)) / f"{sid}.png"
|
|
184
|
+
raw = _api("GET", f"/tabs/{tab_id}/screenshot", query={"userId": user_id, "fullPage": "true"},
|
|
185
|
+
timeout=60, binary=True)
|
|
186
|
+
png.write_bytes(raw if isinstance(raw, (bytes, bytearray)) else b'')
|
|
187
|
+
sess["status"] = "waiting_human_captcha"
|
|
188
|
+
sess["captcha_image"] = str(png)
|
|
189
|
+
sess["instruction"] = ("Решите капчу на скриншоте, пришлите код: "
|
|
190
|
+
f"sud-cli fssp assist --resume {sid} --code <КОД>")
|
|
191
|
+
elif sess["page_state"] == "result":
|
|
192
|
+
sess["status"] = "done"
|
|
193
|
+
sess["results_text"] = sess.get("page_text", "")
|
|
194
|
+
else:
|
|
195
|
+
sess["status"] = sess.get("page_state", "unknown")
|
|
196
|
+
_save(sess)
|
|
197
|
+
return sess
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fill_form(user_id: str, tab_id: str, *, kind: str, name: Optional[str],
|
|
201
|
+
inn: Optional[str], birth_date: Optional[str]) -> dict:
|
|
202
|
+
"""Заполнение формы БДИП по калиброванным подписям (a11y-снапшот 25.10)."""
|
|
203
|
+
script = r"""((kind, name, inn, bd) => {
|
|
204
|
+
const vis = el => el && el.offsetParent !== null;
|
|
205
|
+
const labOf = el => {
|
|
206
|
+
let n = el.previousElementSibling;
|
|
207
|
+
let txt = '';
|
|
208
|
+
while (n && !txt) { txt = (n.innerText || n.textContent || '').trim(); n = n.previousElementSibling; }
|
|
209
|
+
return txt + ' ' + (el.placeholder || '') + ' ' + (el.name || '') + ' ' + (el.id || '');
|
|
210
|
+
};
|
|
211
|
+
const setVal = (el, v) => {
|
|
212
|
+
const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
|
|
213
|
+
if (desc && desc.set) desc.set.call(el, v); else el.value = v;
|
|
214
|
+
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
215
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
216
|
+
};
|
|
217
|
+
const inputs = [...document.querySelectorAll('input')].filter(e => vis(e) && (e.type === 'text' || !e.type));
|
|
218
|
+
const byLabel = re => inputs.find(e => re.test(labOf(e)));
|
|
219
|
+
const steps = [];
|
|
220
|
+
let done = 0;
|
|
221
|
+
const put = (re, val, tag) => {
|
|
222
|
+
if (!val) return;
|
|
223
|
+
const el = byLabel(re);
|
|
224
|
+
if (el) { setVal(el, val); steps.push(tag); done++; }
|
|
225
|
+
};
|
|
226
|
+
if (kind === 'fiz') {
|
|
227
|
+
const parts = (name || '').split(/\s+/);
|
|
228
|
+
put(/Фамилия/i, parts[0], 'last');
|
|
229
|
+
put(/Имя(?! и отчество)/i, parts[1], 'first');
|
|
230
|
+
put(/Отчество/i, parts[2], 'middle');
|
|
231
|
+
if (bd) put(/Дата рождения/i, bd, 'birth');
|
|
232
|
+
} else {
|
|
233
|
+
put(/Наименование предприятия-должника/i, name, 'ur-name');
|
|
234
|
+
put(/ИНН юридического лица/i, inn, 'ur-inn');
|
|
235
|
+
}
|
|
236
|
+
const btns = [...document.querySelectorAll('button, input[type=submit]')].filter(vis);
|
|
237
|
+
const btn = btns.find(b => /найт/i.test((b.innerText || b.value || '').trim()));
|
|
238
|
+
let submitted = false;
|
|
239
|
+
if (btn && done) { btn.click(); submitted = true; steps.push('submit'); }
|
|
240
|
+
return {steps, filled: done, submitted};
|
|
241
|
+
})"""
|
|
242
|
+
body = {"userId": user_id,
|
|
243
|
+
"expression": f"{script}({json.dumps(kind)}, {json.dumps(name)}, {json.dumps(inn)}, {json.dumps(birth_date)})",
|
|
244
|
+
"timeout": 15000}
|
|
245
|
+
out = _api("POST", f"/tabs/{tab_id}/evaluate", body=body)
|
|
246
|
+
res = out.get("result") if isinstance(out, dict) else None
|
|
247
|
+
if not isinstance(res, dict):
|
|
248
|
+
raise FsspAssistError(f"evaluate returned {str(out)[:120]}")
|
|
249
|
+
if not res.get("submitted"):
|
|
250
|
+
raise FsspAssistError(f"форма не распознана (заполнено полей: {res.get('filled')}); используйте fssp check")
|
|
251
|
+
return res
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def resume(state_dir: Path, session_id: str, code: str) -> dict:
|
|
255
|
+
"""Ввести человеческий ответ капчи и забрать результат."""
|
|
256
|
+
sess = _load(session_id, state_dir)
|
|
257
|
+
if not code or not str(code).strip():
|
|
258
|
+
raise FsspAssistError("code required (what the human read from the captcha)")
|
|
259
|
+
user_id, tab_id = sess["user_id"], sess["tab_id"]
|
|
260
|
+
_api("POST", f"/tabs/{tab_id}/type", body={
|
|
261
|
+
"userId": user_id,
|
|
262
|
+
"selector": "input[name*='captch' i], input[id*='captch' i], input[name*='capcha' i], textarea[name*='captch' i]",
|
|
263
|
+
"text": str(code).strip(),
|
|
264
|
+
})
|
|
265
|
+
_api("POST", f"/tabs/{tab_id}/click", body={
|
|
266
|
+
"userId": user_id, "selector": "button, input[type=submit]"})
|
|
267
|
+
_api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 20000})
|
|
268
|
+
snap = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id})
|
|
269
|
+
text = snap.get("text") or snap.get("snapshot") or json.dumps(snap, ensure_ascii=False)
|
|
270
|
+
state = _detect_page(text)
|
|
271
|
+
sess["page_state"] = state
|
|
272
|
+
sess["resumed_at"] = time.time()
|
|
273
|
+
if state == "result":
|
|
274
|
+
sess["status"] = "done"
|
|
275
|
+
sess["results_text"] = text[:4000]
|
|
276
|
+
sess["verdict"] = ("none" if re.search(r"не найдено|отсутству", text, re.I) else "found")
|
|
277
|
+
_close_tab(user_id, tab_id)
|
|
278
|
+
elif state == "limited":
|
|
279
|
+
_breaker_trip(FSSP_URL)
|
|
280
|
+
sess["status"] = "limited"
|
|
281
|
+
_close_tab(user_id, tab_id)
|
|
282
|
+
else:
|
|
283
|
+
sess["status"] = f"need_retry ({state})"
|
|
284
|
+
_save(sess)
|
|
285
|
+
return sess
|
sud_cli/fssp_stats.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""S-61 (В) официальная статистика БДИП ФССП — агрегатный справочник.
|
|
2
|
+
|
|
3
|
+
Акт legal 2026.10.25: общедоступный CSV статистики исполнительных производств
|
|
4
|
+
(fssp.gov.ru/api/is/statistics/csv) годен как вспомогательный источник по
|
|
5
|
+
ст. 7 8-ФЗ (достоверность оперативной информации). Для индивидуальных досье
|
|
6
|
+
статистика проверку НЕ заменяет — только контекст/справки.
|
|
7
|
+
|
|
8
|
+
Сетевой слой: тот же netguard-путь, что и остальные live-адаптеры
|
|
9
|
+
(fail-closed egress, DNS-pin, no-redirect, size cap, per-host breaker на
|
|
10
|
+
401/403/429/5xx-лимиты — ФССП ограничивает частые подключения, это штатно
|
|
11
|
+
уводится в cooldown, не брутфорсится).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import io
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
STATS_URL = "https://fssp.gov.ru/api/is/statistics/csv"
|
|
20
|
+
|
|
21
|
+
_UA = ("Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/126.0")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FsspStatsError(Exception):
|
|
25
|
+
"""Без PII: статистика не содержит персональные данные по построению."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _http_get(url: str, *, timeout: int = 30) -> bytes:
|
|
29
|
+
from sud_cli.sud_netguard import (
|
|
30
|
+
BreakerOpenError,
|
|
31
|
+
breaker_trip,
|
|
32
|
+
check_before_fetch,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
check_before_fetch(url)
|
|
37
|
+
except BreakerOpenError as exc:
|
|
38
|
+
raise FsspStatsError(f"host in cooldown (anti-ban breaker): {exc}") from exc
|
|
39
|
+
|
|
40
|
+
import urllib.error as _ue
|
|
41
|
+
import urllib.request as _ur
|
|
42
|
+
|
|
43
|
+
req = _ur.Request(url, headers={"User-Agent": _UA, "Accept": "text/csv,*/*"})
|
|
44
|
+
|
|
45
|
+
class _NoRedirect(_ur.HTTPRedirectHandler):
|
|
46
|
+
def redirect_request(self, *a, **k):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with _ur.build_opener(_NoRedirect()).open(req, timeout=timeout) as resp:
|
|
51
|
+
raw = resp.read(8_000_000)
|
|
52
|
+
except _ue.HTTPError as exc:
|
|
53
|
+
if exc.code in (301, 302, 303, 307, 308, 401, 403, 429) or exc.code >= 500:
|
|
54
|
+
breaker_trip(url, reason=f"fssp_stats_http_{exc.code}")
|
|
55
|
+
raise FsspStatsError(f"fssp stats http {exc.code}") from exc
|
|
56
|
+
except _ue.URLError as exc:
|
|
57
|
+
raise FsspStatsError("fssp stats unreachable: " + str(exc.reason)[:80]) from exc
|
|
58
|
+
return raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse(raw: bytes) -> list[dict]:
|
|
62
|
+
text = raw.decode("utf-8-sig", errors="replace")
|
|
63
|
+
rows: list[dict] = []
|
|
64
|
+
try:
|
|
65
|
+
reader = csv.DictReader(io.StringIO(text), delimiter=";", quotechar='"')
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
raise FsspStatsError("fssp stats: unparseable csv") from exc
|
|
68
|
+
for row in reader:
|
|
69
|
+
rid = (row.get("id") or "").strip()
|
|
70
|
+
title = (row.get("title") or "").strip()
|
|
71
|
+
count = (row.get("count") or "").strip()
|
|
72
|
+
if not rid or not title:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
n = int(count)
|
|
76
|
+
except ValueError:
|
|
77
|
+
continue
|
|
78
|
+
rows.append({"region_id": rid, "region": title, "active_executions": n})
|
|
79
|
+
if not rows:
|
|
80
|
+
raise FsspStatsError("fssp stats: unexpected payload (no rows)")
|
|
81
|
+
return rows
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def statistics(region: Optional[str] = None, *, timeout: int = 30) -> dict:
|
|
85
|
+
"""Общедоступная статистика БДИП: число активных ИП по подразделениям/регионам.
|
|
86
|
+
|
|
87
|
+
region — опциональный фильтр по id региона (префикс) или названию (подстрока).
|
|
88
|
+
"""
|
|
89
|
+
raw = _http_get(STATS_URL, timeout=timeout)
|
|
90
|
+
rows = _parse(raw)
|
|
91
|
+
total = sum(r["active_executions"] for r in rows if r["region_id"] == "00")
|
|
92
|
+
if region:
|
|
93
|
+
needle = region.strip().lower()
|
|
94
|
+
rows = [r for r in rows
|
|
95
|
+
if r["region_id"] == needle or needle in r["region"].lower()]
|
|
96
|
+
return {
|
|
97
|
+
"source": STATS_URL,
|
|
98
|
+
"provider_label": "fssp.gov.ru (официальная статистика БДИП, ст. 7 8-ФЗ — оперативные данные)",
|
|
99
|
+
"region_filter": region,
|
|
100
|
+
"count": len(rows),
|
|
101
|
+
"rows": rows,
|
|
102
|
+
"note": ("Агрегатная статистика; индивидуальные проверки — ручной контур "
|
|
103
|
+
"fssp check/record (юридическая проверка 25.10)."),
|
|
104
|
+
"center_total_hint": total,
|
|
105
|
+
}
|
sud_cli/holidays_rf.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_note": "Production calendar for the Russian Federation, federal level only.",
|
|
3
|
+
"_source": "ст. 112 ТК РФ (fixed federal non-working holidays) + Government transfer decrees",
|
|
4
|
+
"_scope": "MVP per colleague-legal addendum 1.1.0 п.Б2. Regional holidays are NOT included.",
|
|
5
|
+
"_transfer_provenance": "Decrees were verified against the official portal scan, not transcribed from memory. Each entry records its own decree; a year without an entry keeps the CALENDAR_WARNING.",
|
|
6
|
+
"federal_holidays": {
|
|
7
|
+
"2024": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
|
|
8
|
+
"02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
|
|
9
|
+
"2025": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
|
|
10
|
+
"02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
|
|
11
|
+
"2026": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
|
|
12
|
+
"02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
|
|
13
|
+
"2027": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
|
|
14
|
+
"02-23", "03-08", "05-01", "05-09", "06-12", "11-04"]
|
|
15
|
+
},
|
|
16
|
+
"_transfer_note": "Days moved in by a Government decree (a weekday declared a day off) or out of a holiday. Both directions matter: a day moved OUT becomes an ordinary working day, and one moved IN becomes non-working.",
|
|
17
|
+
"transfers": {
|
|
18
|
+
"2026": {
|
|
19
|
+
"decree": "Постановление Правительства РФ от 24.09.2025 № 1466 «О переносе выходных дней в 2026 году»",
|
|
20
|
+
"decree_number": 1466,
|
|
21
|
+
"decree_date": "2025-09-24",
|
|
22
|
+
"published": "официальный интернет-портал правовой информации, 24.09.2025, N 0001202509240023; СЗ РФ, 29.09.2025, N 39, ст. 5738",
|
|
23
|
+
"url": "http://publication.pravo.gov.ru/document/0001202509240023",
|
|
24
|
+
"amendments_found": false,
|
|
25
|
+
"extra_days_off": ["01-09", "12-31"],
|
|
26
|
+
"extra_days_off_reason": "с субботы 3 января на пятницу 9 января; с воскресенья 4 января на четверг 31 декабря (второй абзац ч. 1 ст. 112 ТК РФ — новогодние переносы делает только Правительство)",
|
|
27
|
+
"days_restored_to_working": []
|
|
28
|
+
},
|
|
29
|
+
"2027": {
|
|
30
|
+
"decree": "Постановление Правительства РФ от 17.09.2026 № 1187 «О переносе выходных дней в 2027 году»",
|
|
31
|
+
"decree_number": 1187,
|
|
32
|
+
"decree_date": "2026-09-17",
|
|
33
|
+
"published": "официальный интернет-портал правовой информации, 18.09.2026, N 0001202609180037",
|
|
34
|
+
"url": "http://publication.pravo.gov.ru/document/0001202609180037",
|
|
35
|
+
"amendments_found": false,
|
|
36
|
+
"extra_days_off": ["02-22", "11-05", "12-31"],
|
|
37
|
+
"extra_days_off_reason": "с субботы 20 февраля на понедельник 22 февраля (выходной переносится на рабочий понедельник, поскольку 23 февраля — праздник); с субботы 2 января на пятницу 5 ноября; с воскресенья 3 января на пятницу 31 декабря. Первые два новогодних переноса делает только Правительство (абз. 2 ч. 1 ст. 112 ТК РФ), первый — из общего правила ч. 2 ст. 112.",
|
|
38
|
+
"days_restored_to_working": ["02-20"],
|
|
39
|
+
"days_restored_to_working_reason": "с субботы 20 февраля на понедельник 22 февраля. Перенос двусторонний: 20 февраля перестаёт быть выходным (иначе появляется лишний нерабочий день), 22 февраля им становится. Без этой строки февраль 2027 даёт 18 рабочих дней вместо 19 по опубликованному календарю.",
|
|
40
|
+
"verification": "Прочитан со скана официального портала (publication.pravo.gov.ru/file/pdf?eoNumber=0001202609180037), 1 страница. Дни недели в тексте постановления сверены: 02.01.2027 = суббота, 03.01.2027 = воскресенье, 20.02.2027 = суббота, 22.02.2027 = понедельник. Контрольный счёт: 247 рабочих / 118 выходных и праздничных за 2027 год — совпадает с опубликованным производственным календарём."
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"_auto_transfer_note": "ч. 2 ст. 112 ТК РФ: a holiday landing on a weekend moves to the next working day automatically, EXCEPT the holidays named in абз. 2 and 3 of the same article (1–8 января, 23 февраля) — for those only the Government may move the day off. The automatic moves below are therefore derived from ст. 112, not from the decree.",
|
|
44
|
+
"auto_transfers_st112": {
|
|
45
|
+
"2026": [
|
|
46
|
+
{"holiday": "03-08", "weekday": "воскресенье", "moved_to": "03-09"},
|
|
47
|
+
{"holiday": "05-09", "weekday": "суббота", "moved_to": "05-11"}
|
|
48
|
+
],
|
|
49
|
+
"2027": [
|
|
50
|
+
{"holiday": "05-01", "weekday": "суббота", "moved_to": "05-03"},
|
|
51
|
+
{"holiday": "05-09", "weekday": "воскресенье", "moved_to": "05-10"},
|
|
52
|
+
{"holiday": "06-12", "weekday": "суббота", "moved_to": "06-14"}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"transfer_decrees_encoded": true,
|
|
56
|
+
"years_with_verified_transfers": [2026, 2027],
|
|
57
|
+
"verified_through": 2027,
|
|
58
|
+
"known_years": [2024, 2025, 2026, 2027],
|
|
59
|
+
"warning_template": "CALENDAR_WARNING: расчёт произведён по базовым выходным (сб/вс), ст. 112 ТК РФ и автопереносам, но перенос Правительства РФ за {year} год НЕ зашит (постановление не подтверждено). Фактические нерабочие дни могут отличаться.",
|
|
60
|
+
"no_warning_template": "Переносы Правительства РФ за {year} год учтены ({decree}). Переносы, введённые позже этой даты, не учтены.",
|
|
61
|
+
"regional_note": "Региональные нерабочие праздничные дни субъектов РФ не учитываются."
|
|
62
|
+
}
|
sud_cli/idempotency.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""W6a §1 (S-80): реестр идемпотентности write-инструментов MCP.
|
|
2
|
+
|
|
3
|
+
Повтор write-вызова с тем же ``idempotency_key`` в окне 10 минут обязан
|
|
4
|
+
вернуть ПЕРВЫЙ результат, а не устроить вторую запись. Реестр живёт в
|
|
5
|
+
state-dir (``idempotency.json``, права 0600 — в значении могут быть реквизиты
|
|
6
|
+
дела) и чистится по тому же окну: запись старше 600 с больше ничего не
|
|
7
|
+
подавляет.
|
|
8
|
+
|
|
9
|
+
Границы: модуль не знает про сеть и про MCP-транспорт — только чтение/запись
|
|
10
|
+
одного файла. Повреждённый реестр трактуется как пустой (fail-open для
|
|
11
|
+
легальных записей): потерянное подавление повтора видимо по audit-журналу, а
|
|
12
|
+
заблокировать законный вызов молча — худший из исходов.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
WINDOW_SECONDS = 600 # окно подавления повторов: 10 минут = первый результат
|
|
22
|
+
REGISTRY_NAME = "idempotency.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def registry_path(state_dir) -> Path:
|
|
26
|
+
return Path(state_dir) / REGISTRY_NAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load(state_dir) -> dict:
|
|
30
|
+
path = registry_path(state_dir)
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, ValueError):
|
|
34
|
+
return {}
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _entry_id(tool: str, key: str) -> str:
|
|
39
|
+
return f"{tool}|{key}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lookup(state_dir, tool: str, key: str, now: float | None = None):
|
|
43
|
+
"""Первый результат по (tool, key), если он ещё в окне; иначе None."""
|
|
44
|
+
now = time.time() if now is None else now
|
|
45
|
+
entry = _load(state_dir).get(_entry_id(tool, key))
|
|
46
|
+
if not isinstance(entry, dict):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
ts = float(entry.get("ts", 0))
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return None
|
|
52
|
+
if now - ts > WINDOW_SECONDS:
|
|
53
|
+
return None
|
|
54
|
+
result = entry.get("result")
|
|
55
|
+
return result if isinstance(result, dict) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record(state_dir, tool: str, key: str, result: dict, now: float | None = None) -> None:
|
|
59
|
+
"""Сохранить первый результат; записи вне окна вычищаются заодно."""
|
|
60
|
+
now = time.time() if now is None else now
|
|
61
|
+
state_dir = Path(state_dir)
|
|
62
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
data = _load(state_dir)
|
|
64
|
+
data = {
|
|
65
|
+
k: v for k, v in data.items()
|
|
66
|
+
if isinstance(v, dict) and (now - float(v.get("ts") or 0)) <= WINDOW_SECONDS
|
|
67
|
+
}
|
|
68
|
+
data[_entry_id(tool, key)] = {"ts": now, "result": result}
|
|
69
|
+
path = registry_path(state_dir)
|
|
70
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
71
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
72
|
+
try:
|
|
73
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
74
|
+
json.dump(data, fh, ensure_ascii=False, sort_keys=True)
|
|
75
|
+
os.replace(tmp, path)
|
|
76
|
+
finally:
|
|
77
|
+
tmp.unlink(missing_ok=True)
|
|
78
|
+
os.chmod(path, 0o600)
|