feat(laws): full statutory corpus builder and visual analytics 2026

This commit is contained in:
MatrixHasYou 2026-10-13 09:20:13 +04:00
parent 0cdfa04d77
commit ee1989a317
5 changed files with 662 additions and 2 deletions

BIN
email_stats_2026.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

153
fetch_and_build_remaining_laws.py Executable file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
Скрипт автономной сборки официальных полных текстов действующих нормативно-правовых актов РФ
с портала КонсультантПлюс в структурированные PDF через wkhtmltopdf.
Собираемые документы:
1. Постановление Правительства РФ № 354 (cons_doc_LAW_114247)
2. Постановление Госстроя РФ № 170 (cons_doc_LAW_44772)
3. СанПиН 2.1.3684-21 (cons_doc_LAW_376166)
4. Приказ Генеральной прокуратуры РФ № 45 (cons_doc_LAW_141740)
"""
import os
import sys
import time
import subprocess
import urllib.request
from bs4 import BeautifulSoup
import fitz
HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
DOCS = [
{
'law_id': 'cons_doc_LAW_114247',
'out_name': '04_ПП_РФ_354_Коммунальные_Услуги_Полный.pdf',
'title_prefix': 'Постановление Правительства РФ от 06.05.2011 № 354'
},
{
'law_id': 'cons_doc_LAW_44772',
'out_name': '06_Постановление_Госстроя_170_Эксплуатация_Жилфонда_Полный.pdf',
'title_prefix': 'Постановление Госстроя РФ от 27.09.2003 № 170 (МДК 2-03.2003)'
},
{
'law_id': 'cons_doc_LAW_376166',
'out_name': '07_СанПиН_2.1.3684-21_Санитарные_Требования_МКД_Полный.pdf',
'title_prefix': 'СанПиН 2.1.3684-21 (Постановление Главного санврача РФ № 3)'
},
{
'law_id': 'cons_doc_LAW_141740',
'out_name': '08_Приказ_Генпрокуратуры_45_Инструкция_Рассмотрения_Обращений_Полный.pdf',
'title_prefix': 'Приказ Генеральной прокуратуры РФ от 30.01.2013 № 45'
}
]
DEST_DIR = '/home/matrixhasyou/mutt/legal_sources'
os.makedirs(DEST_DIR, exist_ok=True)
def build_law(law_info):
law_id = law_info['law_id']
out_pdf = os.path.join(DEST_DIR, law_info['out_name'])
base_url = f'https://www.consultant.ru/document/{law_id}/'
print(f"\n=======================================================")
print(f"[*] Сборка документа: {law_info['title_prefix']} ({law_id})")
print(f"=======================================================")
req = urllib.request.Request(base_url, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
soup = BeautifulSoup(resp.read().decode('utf-8', errors='ignore'), 'html.parser')
except Exception as e:
print(f"[-] Ошибка загрузки оглавления {base_url}: {e}")
return False
raw_title = soup.find('title').text
main_title = raw_title.split('\\')[0].strip() if '\\' in raw_title else raw_title.strip()
links = []
seen = set()
for a in soup.find_all('a', href=True):
href = a['href']
if href.startswith(f'/document/{law_id}/') and len(href) > len(f'/document/{law_id}/'):
clean_href = href.split('#')[0]
if clean_href not in seen:
seen.add(clean_href)
links.append((clean_href, a.text.strip()))
print(f"[+] Найдено разделов/статей: {len(links)}")
html_parts = [f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: 'DejaVu Sans', sans-serif; margin: 25mm 20mm; font-size: 10pt; line-height: 1.45; color: #111; }}
h1 {{ font-size: 14pt; text-align: center; border-bottom: 2px solid #333; padding-bottom: 8px; margin-bottom: 20px; }}
h2 {{ font-size: 11pt; margin-top: 22px; border-bottom: 1px solid #777; padding-bottom: 4px; color: #002b49; page-break-after: avoid; }}
p {{ margin: 6px 0; text-align: justify; text-indent: 1.5em; }}
table {{ width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 9pt; }}
th, td {{ border: 1px solid #777; padding: 5px 8px; text-align: left; }}
th {{ background-color: #f0f0f0; }}
.header-meta {{ text-align: center; font-style: italic; margin-bottom: 25px; color: #444; }}
</style>
</head>
<body>
<h1>{main_title}</h1>
<div class="header-meta">Официальный действующий текст в актуальной редакции со всеми изменениями и приложениями</div>
"""]
for i, (href, sec_title) in enumerate(links, 1):
url = 'https://www.consultant.ru' + href
print(f" [{i}/{len(links)}] Загрузка: {sec_title[:60]}...")
r = urllib.request.Request(url, headers=HEADERS)
try:
with urllib.request.urlopen(r, timeout=20) as res:
sec_soup = BeautifulSoup(res.read().decode('utf-8', errors='ignore'), 'html.parser')
html_parts.append(f'<h2>{sec_title}</h2>')
for elem in sec_soup.find_all(['p', 'table']):
if elem.name == 'p':
t = elem.text.strip()
if t and not 'Подготовлена редакция' in t and not 'КонсультантПлюс' in t and not 'Открыть документ' in t:
html_parts.append(f'<p>{t}</p>')
elif elem.name == 'table':
html_parts.append(str(elem))
time.sleep(0.2)
except Exception as e:
print(f" [!] Ошибка загрузки раздела {href}: {e}")
html_parts.append('</body></html>')
tmp_html = f'/tmp/{law_id}.html'
with open(tmp_html, 'w', encoding='utf-8') as f:
f.write('\n'.join(html_parts))
print(f"[*] Компиляция PDF через wkhtmltopdf: {out_pdf}...")
subprocess.run([
'wkhtmltopdf',
'--encoding', 'utf-8',
'--margin-top', '20mm',
'--margin-bottom', '20mm',
'--margin-left', '15mm',
'--margin-right', '15mm',
tmp_html, out_pdf
], check=True)
try:
doc = fitz.open(out_pdf)
pages_count = len(doc)
except Exception:
pages_count = 0
size_kb = os.path.getsize(out_pdf) / 1024
print(f"[✔] УСПЕШНО СОЗДАН: {out_pdf} ({size_kb:.1f} КБ, {pages_count} страниц)")
return True
def main():
for doc_info in DOCS:
build_law(doc_info)
print("\n[🎉] ВСЕ ЗАКОНЫ РФ ПОЛНОСТЬЮ СОБРАНЫ В PDF!")
if __name__ == '__main__':
main()

View file

@ -0,0 +1,507 @@
#!/usr/bin/env python3
import os
import sys
import re
import fitz # PyMuPDF
from datetime import date, datetime
from pathlib import Path
import requests
from weasyprint import HTML
# Telegram settings
BOT_TOKEN = "8725618164:AAH1tGalq-pw1l0t4P5c0sdLkCVJdh7IE7M"
ADMIN_USER_ID = "197957361"
# Domain mapping for human-readable naming
DOMAINS_MAP = {
"gosuslugi.ru": "Госуслуги РФ",
"dom.gosuslugi.ru": "ГИС ЖКХ",
"sudrf.ru": "Судебная система РФ",
"zavolgskiy.uln@sudrf.ru": "Заволжский районный суд",
"ulgss.ru": "УО Жилстройсервис",
"ulgkh.ru": "Департамент ЖКХ",
"fssp.gov.ru": "ФССП России",
"fssprus.ru": "ФССП России",
"gov.ru": "Правительство РФ",
"ulgov.ru": "Администрация Ульяновской обл.",
"russianpost.ru": "Почта России",
"rkn.gov.ru": "Роскомнадзор",
"nalog.gov.ru": "ФНС России",
"nalog.ru": "ФНС России",
"genproc.gov.ru": "Прокуратура РФ",
"epp.genproc.gov.ru": "Прокуратура РФ",
"mvd.gov.ru": "МВД России",
"mvd.ru": "МВД России",
"rospotrebnadzor.ru": "Роспотребнадзор",
"pfr.gov.ru": "ПФР / СФР России",
"pfrf.ru": "ПФР / СФР России",
"fss.ru": "Фонд социального страхования",
"sledcom.ru": "Следственный комитет РФ",
"vkks.ru": "ВККС РФ",
"mailop.ru": "Почтовый шлюз Mailop",
"73.mailop.ru": "Почтовый шлюз 73.Mailop",
"uk-service.ru": "УК Сервис-М",
"dgi.ru": "Департамент гос. имущества",
"mos.ru": "Портал Москвы mos.ru",
"fsin.gov.ru": "ФСИН России",
"skrf.ru": "Следственный комитет РФ",
"ugpr.ru": "Управление госзакупок",
"fas.gov.ru": "ФАС России",
"minstroyrf.gov.ru": "Минстрой РФ",
"gkh.ru": "Портал ЖКХ",
"msud.ru": "Мировые судьи РФ",
"arbitr.ru": "Арбитражные суды РФ",
"mirsud": "Мировые судьи",
"msud73.ru": "Мировые судьи Ульяновской обл.",
"mirsud73.ru": "Мировые судьи Ульяновской обл.",
"court.ru": "Судебный департамент РФ",
"sud.ru": "Судебный департамент РФ",
"uloblsud.ru": "Ульяновский областной суд",
"duma.gov.ru": "Государственная Дума РФ",
}
def send_telegram_photo(photo_bytes, caption):
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto"
files = {
"photo": ("stats.png", photo_bytes, "image/png")
}
data = {
"chat_id": ADMIN_USER_ID,
"caption": caption,
"parse_mode": "HTML"
}
print("Отправка отчета в Telegram...")
try:
r = requests.post(url, data=data, files=files, timeout=30)
if r.status_code == 200:
print("🎉 Картинка со статистикой успешно отправлена!")
else:
print(f"❌ Ошибка отправки: {r.status_code} - {r.text}")
except Exception as e:
print(f"❌ Ошибка отправки запроса в Telegram: {e}")
def main():
folder = Path("/home/matrixhasyou/mutt/exported_emails/all")
if not folder.exists():
print(f"Папка не существует: {folder}")
sys.exit(1)
pdf_files = list(folder.glob("*.pdf"))
print(f"Всего PDF файлов найдено: {len(pdf_files)}")
stats = {}
# Summary counters
total_in_mails = 0
total_in_pages = 0
total_in_atts = 0
total_out_mails = 0
total_out_pages = 0
total_out_atts = 0
# Process files
for filepath in pdf_files:
filename = filepath.name
# Parse filename
# Format is typically: {match_domain}_{date_part}_{safe_from}_{safe_subject}.pdf
# Let's extract date
date_match = re.search(r'_(\d{4}-\d{2}-\d{2})_', filename)
if not date_match:
# Maybe it starts with date: YYYY-MM-DD_domain_...
date_match = re.match(r'^(\d{4}-\d{2}-\d{2})_', filename)
if not date_match:
continue
date_str = date_match.group(1)
if not date_str.startswith("2026"):
continue
# Parse domain and sender
parts = filename.split("_", 2)
if len(parts) >= 2:
# If domain first
domain = parts[0]
# Verify if it looks like domain
if not ("." in domain or "судебное" in domain or "other" in domain):
# Fallback to general parsing
domain = "other"
else:
domain = "other"
try:
doc = fitz.open(filepath)
num_pages = len(doc)
first_page_text = doc[0].get_text()
# Determine direction (inbound / outbound)
is_outbound = False
first_page_lower = first_page_text.lower()
# Heuristics
if "от: ktybdsq@gmail.com" in first_page_lower or "from: ktybdsq@gmail.com" in first_page_lower:
is_outbound = True
elif "_ktybdsq_" in filename.lower():
is_outbound = True
elif "fwd_" in filename.lower() and "от: ktybdsq@gmail.com" in first_page_lower:
is_outbound = True
# Attachment count
att_count = 0
att_match = re.search(r"(?:Вложения|Attachments):(.*)", first_page_text, re.DOTALL | re.IGNORECASE)
if att_match:
block_text = att_match.group(1)
# Count bytes occurrences as an indicator of attachments
att_count = len(re.findall(r"bytes\)", block_text))
doc.close()
# Add to stats
if domain not in stats:
stats[domain] = {
'inbound': {'mails': 0, 'pages': 0, 'atts': 0},
'outbound': {'mails': 0, 'pages': 0, 'atts': 0}
}
direction = 'outbound' if is_outbound else 'inbound'
stats[domain][direction]['mails'] += 1
stats[domain][direction]['pages'] += num_pages
stats[domain][direction]['atts'] += att_count
if is_outbound:
total_out_mails += 1
total_out_pages += num_pages
total_out_atts += att_count
else:
total_in_mails += 1
total_in_pages += num_pages
total_in_atts += att_count
except Exception as e:
print(f"Ошибка парсинга {filename}: {e}")
# Group results to consolidate subdomains or same entities
grouped_stats = {}
for dom, data in stats.items():
mapped_name = DOMAINS_MAP.get(dom, dom)
# General groupings for cleaner output
if "sudrf.ru" in dom or "mirsud" in dom or "msud" in dom or "court" in dom or "arbitr" in dom:
mapped_name = "Судебная система РФ"
elif "gosuslugi.ru" in dom:
mapped_name = "Портал Госуслуг РФ"
elif "gov.ru" in dom or "ulgov.ru" in dom:
mapped_name = "Органы власти / Ведомства"
elif "fssp.gov.ru" in dom or "fssprus.ru" in dom:
mapped_name = "ФССП (Судебные приставы)"
elif "nalog.ru" in dom or "nalog.gov.ru" in dom:
mapped_name = "ФНС (Налоговая служба)"
if mapped_name not in grouped_stats:
grouped_stats[mapped_name] = {
'inbound': {'mails': 0, 'pages': 0, 'atts': 0},
'outbound': {'mails': 0, 'pages': 0, 'atts': 0}
}
for dir_key in ['inbound', 'outbound']:
for val_key in ['mails', 'pages', 'atts']:
grouped_stats[mapped_name][dir_key][val_key] += data[dir_key][val_key]
# Sort domains by total mail activity
sorted_domains = sorted(
grouped_stats.keys(),
key=lambda k: grouped_stats[k]['inbound']['mails'] + grouped_stats[k]['outbound']['mails'],
reverse=True
)
# Render HTML to PDF and PNG
html_content = """
<html>
<head>
<meta charset='utf-8'>
<style>
@page {
size: A4 landscape;
margin: 0;
}
body {
font-family: 'DejaVu Sans', sans-serif;
margin: 0;
padding: 30px;
background: linear-gradient(135deg, #0b0f19 0%, #111827 100%);
color: #f3f4f6;
box-sizing: border-box;
width: 297mm;
height: 210mm;
}
.container {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 24, 39, 0.95);
border-radius: 20px;
padding: 25px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.6);
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 15px;
margin-bottom: 15px;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
background: linear-gradient(to right, #00d2ff, #3a7bd5);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.5px;
}
.header .subtitle {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
.grid {
display: flex;
gap: 25px;
height: calc(100% - 75px);
}
.table-container {
flex: 2.3;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 11px;
}
th {
background: rgba(31, 41, 55, 0.8);
color: #38bdf8;
padding: 10px 12px;
font-weight: bold;
border-bottom: 2px solid rgba(255, 255, 255, 0.15);
text-transform: uppercase;
letter-spacing: 0.3px;
}
td {
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
.domain-cell {
font-weight: bold;
color: #f3f4f6;
}
.inbound-text {
color: #10b981;
font-weight: 600;
}
.outbound-text {
color: #ef4444;
font-weight: 600;
}
.summary-container {
flex: 0.7;
background: rgba(31, 41, 55, 0.4);
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid rgba(255, 255, 255, 0.06);
height: 100%;
box-sizing: border-box;
}
.summary-title {
font-size: 15px;
font-weight: bold;
color: #818cf8;
margin-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-box {
margin-bottom: 14px;
}
.stat-label {
font-size: 9.5px;
color: #9ca3af;
text-transform: uppercase;
margin-bottom: 2px;
letter-spacing: 0.3px;
}
.stat-value {
font-size: 22px;
font-weight: bold;
color: #f9fafb;
}
.stat-value.in {
color: #10b981;
}
.stat-value.out {
color: #ef4444;
}
.footer-info {
font-size: 9px;
color: #4b5563;
text-align: right;
line-height: 1.4;
}
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<div>
<h1>⚖️ ОФИЦИАЛЬНАЯ ПЕРЕПИСКА С ВЕДОМСТВАМИ (2026 Г.)</h1>
<div class='subtitle'>Анализ входящих и исходящих официальных документов и страниц</div>
</div>
<div style='text-align: right;'>
<span style='font-size: 11px; color: #9ca3af;'>Период: 01.01.2026 - """ + date.today().strftime('%d.%m.%Y') + """</span>
</div>
</div>
<div class='grid'>
<div class='table-container'>
<table>
<thead>
<tr>
<th rowspan="2">Ведомство / Организация</th>
<th colspan="3" style="text-align: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1);" class="inbound-text">📥 Входящие (мне)</th>
<th colspan="3" style="text-align: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1);" class="outbound-text">📤 Исходящие (от меня)</th>
</tr>
<tr>
<th style="text-align: center;">Писем</th>
<th style="text-align: center;">Влож.</th>
<th style="text-align: center;">Стр.</th>
<th style="text-align: center;">Писем</th>
<th style="text-align: center;">Влож.</th>
<th style="text-align: center;">Стр.</th>
</tr>
</thead>
<tbody>
"""
for dom in sorted_domains:
in_m = grouped_stats[dom]['inbound']['mails']
in_a = grouped_stats[dom]['inbound']['atts']
in_p = grouped_stats[dom]['inbound']['pages']
out_m = grouped_stats[dom]['outbound']['mails']
out_a = grouped_stats[dom]['outbound']['atts']
out_p = grouped_stats[dom]['outbound']['pages']
if in_m == 0 and out_m == 0:
continue
html_content += f"""
<tr>
<td class='domain-cell'>{dom}</td>
<td style="text-align: center;" class="inbound-text">{in_m if in_m > 0 else '-'}</td>
<td style="text-align: center;">{in_a if in_a > 0 else '-'}</td>
<td style="text-align: center;">{in_p if in_p > 0 else '-'}</td>
<td style="text-align: center;" class="outbound-text">{out_m if out_m > 0 else '-'}</td>
<td style="text-align: center;">{out_a if out_a > 0 else '-'}</td>
<td style="text-align: center;">{out_p if out_p > 0 else '-'}</td>
</tr>
"""
html_content += f"""
<tr style="background: rgba(56, 189, 248, 0.08); font-weight: bold; border-top: 2px solid rgba(255, 255, 255, 0.15);">
<td style="color: #38bdf8;">ИТОГО:</td>
<td style="text-align: center;" class="inbound-text">{total_in_mails}</td>
<td style="text-align: center;">{total_in_atts}</td>
<td style="text-align: center;">{total_in_pages}</td>
<td style="text-align: center;" class="outbound-text">{total_out_mails}</td>
<td style="text-align: center;">{total_out_atts}</td>
<td style="text-align: center;">{total_out_pages}</td>
</tr>
</tbody>
</table>
</div>
<div class='summary-container'>
<div>
<div class='summary-title'>📊 Сводные данные</div>
<div class='stat-box'>
<div class='stat-label'>Всего писем</div>
<div class='stat-value' style='color: #38bdf8;'>{total_in_mails + total_out_mails}</div>
</div>
<div class='stat-box'>
<div class='stat-label'>Всего файлов / вложений</div>
<div class='stat-value'>{total_in_atts + total_out_atts} шт.</div>
</div>
<div class='stat-box'>
<div class='stat-label'>Общий объем документов</div>
<div class='stat-value' style='color: #818cf8;'>{total_in_pages + total_out_pages} стр.</div>
</div>
<div class='stat-box' style='border-top: 1px solid rgba(255, 255, 255, 0.08); padding-top: 8px;'>
<div class='stat-label'>Входящий поток</div>
<div class='stat-value inbound-text' style='font-size: 16px;'>{total_in_mails} писем / {total_in_pages} стр.</div>
</div>
<div class='stat-box'>
<div class='stat-label'>Исходящий поток</div>
<div class='stat-value outbound-text' style='font-size: 16px;'>{total_out_mails} писем / {total_out_pages} стр.</div>
</div>
</div>
<div class='footer-info'>
LKM37 CORE v4.3<br>
Сгенерировано ИИ Antigravity
</div>
</div>
</div>
</div>
</body>
</html>
"""
# Render HTML to PDF using WeasyPrint
print("Рендеринг HTML отчета в PDF...")
pdf_bytes = HTML(string=html_content).write_pdf()
# Convert PDF to PNG
print("Конвертация PDF в PNG...")
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
page = doc[0]
pix = page.get_pixmap(dpi=180)
png_bytes = pix.tobytes("png")
doc.close()
# Save the output PNG image
out_png_path = Path("/home/matrixhasyou/mutt/email_stats_2026.png")
out_png_path.write_bytes(png_bytes)
print(f"✅ Итоговый график сохранен: {out_png_path}")
# Send to Telegram
caption = (
f"📊 <b>Статистика переписки с ведомствами за весь 2026 год</b>\n\n"
f"🔹 Всего писем: <b>{total_in_mails + total_out_mails}</b>\n"
f"📥 Входящих: <b>{total_in_mails}</b> (вложений: {total_in_atts}, страниц: {total_in_pages})\n"
f"📤 Исходящих: <b>{total_out_mails}</b> (вложений: {total_out_atts}, страниц: {total_out_pages})\n"
f"📁 Общий объем архива: <b>{total_in_pages + total_out_pages} страниц</b>"
)
send_telegram_photo(png_bytes, caption)
if __name__ == "__main__":
main()

View file

@ -1 +1 @@
2026-06-26 21:41:52 2026-10-13 02:00:34

View file

@ -1 +1 @@
{"status": "success", "step": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", "updated_at": "2026-06-26 21:41:52", "new_emails": 1, "total_emails": 0, "pdf_size_mb": 180.84} {"status": "success", "step": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", "updated_at": "2026-10-13 02:00:35", "new_emails": 2, "total_emails": 612, "pdf_size_mb": 203.87}