Initial deployable Flask application
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
"""
|
||||
|
||||
from .obfuscator import TwoPassObfuscator, obfuscate_files
|
||||
from .llm_client import LLMClient
|
||||
|
||||
__all__ = ["TwoPassObfuscator", "obfuscate_files", "LLMClient"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Сборка результата обфускации.
|
||||
|
||||
Два метода:
|
||||
1. _build_zip — упаковка обфусцированных файлов в ZIP
|
||||
2. _build_mapping_csv — генерация mapping.csv с таблицей замен
|
||||
"""
|
||||
|
||||
import io
|
||||
import csv
|
||||
import os
|
||||
import zipfile
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
|
||||
|
||||
|
||||
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
"""Собрать ZIP-архив с обфусцированными файлами.
|
||||
|
||||
В архив добавляются ТОЛЬКО обфусцированные файлы (с оригинальными именами).
|
||||
mapping.csv в архив НЕ кладётся — это таблица соответствия оригинал→замена,
|
||||
т.е. ключ расшифровки (секретные данные). Он хранится/скачивается отдельно
|
||||
(/api/csv), чтобы нельзя было расшифровать архив, получив один ZIP.
|
||||
|
||||
Имена файлов в архиве — UTF-8 (бит 11 в flag_bits).
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes), ...]
|
||||
mapping_csv: Строка CSV с таблицей замен (опционально, НЕ добавляется в архив)
|
||||
|
||||
Returns:
|
||||
Бинарное содержимое ZIP-архива
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
|
||||
# Дедупликация имён: имя+одинаковый контент → пропуск; имя+разный контент → суффикс
|
||||
seen: Dict[str, bytes] = {}
|
||||
|
||||
def _resolve_name(name: str, content: bytes) -> str:
|
||||
if name not in seen:
|
||||
seen[name] = content
|
||||
return name
|
||||
if seen[name] == content:
|
||||
return None # точный дубль — пропускаем
|
||||
base, ext = os.path.splitext(name)
|
||||
n = 2
|
||||
while f"{base}_{n}{ext}" in seen:
|
||||
n += 1
|
||||
resolved = f"{base}_{n}{ext}"
|
||||
seen[resolved] = content
|
||||
return resolved
|
||||
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Добавляем обфусцированные файлы
|
||||
for fname, content in files:
|
||||
out_name = _resolve_name(fname, content)
|
||||
if out_name is None:
|
||||
continue
|
||||
info = zipfile.ZipInfo(out_name)
|
||||
info.date_time = time.localtime(time.time() + 3 * 3600)[:6] # MSK (UTC+3)
|
||||
info.flag_bits |= 0x800 # Флаг: имя файла в UTF-8
|
||||
zf.writestr(info, content)
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build_mapping_csv(mapping: Dict[str, str]) -> str:
|
||||
"""Собрать mapping.csv — таблицу соответствия оригинал → замена.
|
||||
|
||||
Колонки:
|
||||
- тип_данных: тип сущности (phone, email, inn_ul, company, ...)
|
||||
- оригинал: исходное значение из документа
|
||||
- замена: фиктивное значение
|
||||
|
||||
Тип определяется проверкой каждого значения через ENTITY_PATTERNS
|
||||
и COMPANY_PATTERN. Если ни один паттерн не подошёл — тип "text".
|
||||
|
||||
Args:
|
||||
mapping: Словарь {оригинал: замена}
|
||||
|
||||
Returns:
|
||||
Строка в формате CSV
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["тип_данных", "оригинал", "замена"])
|
||||
|
||||
# Порядок проверки типов: inn_fl ДО inn_ul (12 цифр vs 10)
|
||||
type_order = [
|
||||
"phone", "email", "inn_fl", "inn_ul", "ogrn",
|
||||
"kpp", "bik", "rs", "ks", "passport",
|
||||
]
|
||||
|
||||
for original, replacement in sorted(mapping.items()):
|
||||
# Определяем тип сущности
|
||||
etype = "text" # По умолчанию
|
||||
|
||||
for t in type_order:
|
||||
pat = ENTITY_PATTERNS.get(t, "")
|
||||
if pat and re.match(pat, original, re.IGNORECASE):
|
||||
etype = t
|
||||
break
|
||||
|
||||
# Компании проверяем отдельно (COMPANY_PATTERN не в ENTITY_PATTERNS)
|
||||
if COMPANY_PATTERN.match(original):
|
||||
etype = "company"
|
||||
|
||||
writer.writerow([etype, original, replacement])
|
||||
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Константы и словари для DrHider.
|
||||
|
||||
Содержит:
|
||||
- ENTITY_PATTERNS — regex-паттерны для обнаружения сущностей
|
||||
- COMPANY_PATTERN — паттерн для обнаружения названий компаний
|
||||
- PERSON_PATTERN — паттерн для обнаружения ФИО
|
||||
- Словари русских имён, городов, улиц
|
||||
- Список фиктивных доменов для email
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Regex-паттерны для обнаружения сущностей в тексте документов
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ENTITY_PATTERNS = {
|
||||
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
|
||||
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
|
||||
# 12-значный ИНН ДО 10-значного — иначе 12-значный матчится как 10-значный
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
"inn_ul": r'ИНН\s*\d{10}',
|
||||
"ogrn": r'ОГРН\s*\d{13}',
|
||||
"kpp": r'КПП\s*\d{9}',
|
||||
"bik": r'БИК\s*\d{9}',
|
||||
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
|
||||
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
|
||||
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Паттерны для LLM-независимого обнаружения (regex)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Названия компаний: ООО/ЗАО/АО/ПАО/ИП/ТОО + название в кавычках или без
|
||||
COMPANY_PATTERN = re.compile(
|
||||
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ФИО: Фамилия + инициалы (Иванов И.И.) или полное имя (Иванов Иван Иванович)
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Словари для генерации фиктивных значений
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Русские фамилии (20 самых распространённых)
|
||||
RU_SURNAMES = [
|
||||
"Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
|
||||
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков",
|
||||
"Алексеев", "Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов",
|
||||
"Степанов", "Николаев",
|
||||
]
|
||||
|
||||
# Русские мужские имена
|
||||
RU_NAMES = [
|
||||
"Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
|
||||
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел",
|
||||
"Виктор", "Олег",
|
||||
]
|
||||
|
||||
# Русские отчества
|
||||
RU_PATRONYMICS = [
|
||||
"Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
|
||||
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
|
||||
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович",
|
||||
]
|
||||
|
||||
# Города России (15 крупнейших)
|
||||
RU_CITIES = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск",
|
||||
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград",
|
||||
]
|
||||
|
||||
# Названия улиц
|
||||
RU_STREETS = [
|
||||
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
|
||||
]
|
||||
|
||||
# Фиктивные домены для генерации email
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Пулы для гибридных токенов (шаблон + номер)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Существительные для названий компаний (20)
|
||||
COMPANY_NOUNS = [
|
||||
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан",
|
||||
"Спектр", "Формат", "Модуль", "Ресурс", "Платформа",
|
||||
"Система", "Комплекс", "Проект", "Решение", "Сервис",
|
||||
]
|
||||
|
||||
# Города для адресов (20)
|
||||
CITY_POOL = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний_Новгород", "Челябинск", "Самара", "Омск",
|
||||
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь",
|
||||
"Волгоград", "Краснодар", "Саратов", "Тюмень", "Иркутск", "Хабаровск",
|
||||
]
|
||||
|
||||
# Улицы для адресов (20)
|
||||
STREET_POOL = [
|
||||
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
|
||||
"Школьная", "Лесная", "Полевая", "Строителей", "Набережная",
|
||||
"Парковая", "Солнечная", "Зелёная", "Юбилейная", "Весенняя",
|
||||
]
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Извлечение текста из документов — все форматы → Markdown.
|
||||
|
||||
Поддерживает:
|
||||
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
|
||||
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
|
||||
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
|
||||
- .txt и прочие → как есть
|
||||
|
||||
Также содержит:
|
||||
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DOCX → Markdown
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def docx_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать DOCX в Markdown с сохранением базового форматирования.
|
||||
|
||||
Маппинг стилей:
|
||||
- Heading 1/2/3 → # / ## / ###
|
||||
- Bold → **текст**
|
||||
- Italic → *текст*
|
||||
- Таблицы → | col1 | col2 | (с разделителем)
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .docx файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown
|
||||
"""
|
||||
from docx import Document
|
||||
|
||||
doc = Document(io.BytesIO(content))
|
||||
lines = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
if not para.text.strip():
|
||||
lines.append("") # Пустая строка — разделитель абзацев
|
||||
continue
|
||||
|
||||
text = para.text
|
||||
|
||||
# Применяем форматирование из runs (bold, italic)
|
||||
if para.runs:
|
||||
formatted_parts = []
|
||||
for run in para.runs:
|
||||
part = run.text
|
||||
if run.bold:
|
||||
part = f"**{part}**"
|
||||
if run.italic:
|
||||
part = f"*{part}*"
|
||||
formatted_parts.append(part)
|
||||
text = "".join(formatted_parts)
|
||||
|
||||
# Стиль абзаца
|
||||
style_name = para.style.name if para.style else ""
|
||||
|
||||
if style_name.startswith("Heading"):
|
||||
# Heading 1 → #, Heading 2 → ##, ...
|
||||
try:
|
||||
level = int(style_name.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
level = 1
|
||||
prefix = "#" * min(level, 6)
|
||||
lines.append(f"{prefix} {text}")
|
||||
else:
|
||||
lines.append(text)
|
||||
|
||||
# ── Таблицы ──
|
||||
for table in doc.tables:
|
||||
lines.append("") # Отступ перед таблицей
|
||||
for ri, row in enumerate(table.rows):
|
||||
cells = [cell.text.replace("\n", " ").strip() for cell in row.cells]
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
# Разделитель после первой строки (заголовка таблицы)
|
||||
if ri == 0:
|
||||
lines.append("|" + "|".join(["---"] * len(cells)) + "|")
|
||||
lines.append("") # Отступ после таблицы
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PDF → Markdown
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def pdf_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать PDF в Markdown (только текст + таблицы, без форматирования).
|
||||
|
||||
pdfplumber не различает стили (bold/italic/heading), поэтому
|
||||
всё извлекается как plain text.
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .pdf файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown (plain text + таблицы)
|
||||
"""
|
||||
import pdfplumber
|
||||
|
||||
lines = []
|
||||
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
# ── Текст страницы ──
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
lines.append(text)
|
||||
lines.append("")
|
||||
|
||||
# ── Таблицы ──
|
||||
# Быстрый фильтр: если на странице нет линий/кривых/прямоугольников,
|
||||
# таблиц нет (pdfplumber ищет таблицы только по векторным линиям).
|
||||
# Включая page.rects — таблицы на цветном фоне (заливка).
|
||||
# Пропускаем extract_tables() на «чистых» страницах (скан/текст) — это
|
||||
# основная стоимость на сканах, где линий нет.
|
||||
if page.lines or page.curves or page.rects:
|
||||
tables = page.extract_tables()
|
||||
else:
|
||||
tables = []
|
||||
for table in tables:
|
||||
if not table:
|
||||
continue
|
||||
|
||||
# Чистим строки таблицы
|
||||
clean_rows = [
|
||||
[str(c or "").strip() for c in (row or [])]
|
||||
for row in table
|
||||
]
|
||||
clean_rows = [r for r in clean_rows if any(r)]
|
||||
|
||||
if not clean_rows:
|
||||
continue
|
||||
|
||||
# Выводим таблицу в MD
|
||||
for ri, row in enumerate(clean_rows):
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
if ri == 0:
|
||||
lines.append("|" + "|".join(["---"] * len(row)) + "|")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DOC → Markdown (через сервис liberta)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def doc_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать бинарный .doc → .docx (сервис liberta) → Markdown.
|
||||
|
||||
Отправляет .doc на HTTP-сервис конвертации, получает .docx,
|
||||
затем прогоняет через штатный docx_to_markdown().
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .doc файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown (или сообщение об ошибке)
|
||||
"""
|
||||
import httpx
|
||||
url = os.environ.get(
|
||||
"CONVERT_SERVICE_URL",
|
||||
"https://liberta.containerk8s.dev.nubes.ru/convert"
|
||||
)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.post(
|
||||
url,
|
||||
files={"file": ("input.doc", content, "application/msword")}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return docx_to_markdown(resp.content)
|
||||
err = resp.json().get("error", f"HTTP {resp.status_code}")
|
||||
log.warning("liberta conversion failed: %s", err)
|
||||
return f"[DOC — conversion failed: {err}]"
|
||||
except httpx.TimeoutException:
|
||||
return "[DOC — conversion timed out (>120s)]"
|
||||
except Exception as e:
|
||||
log.warning("liberta error: %s", e)
|
||||
return f"[DOC — conversion error: {e}]"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Единая точка входа
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
|
||||
"""Извлечь текст из одного файла → Markdown.
|
||||
|
||||
Args:
|
||||
fname: Имя файла (с расширением)
|
||||
content: Бинарное содержимое файла
|
||||
ctype: MIME-тип (зарезервировано, не используется)
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown
|
||||
"""
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
# ── .docx: полный Markdown с форматированием ──
|
||||
if ext == '.docx':
|
||||
try:
|
||||
return docx_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
# ── .pdf: текст + таблицы ──
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
return pdf_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
|
||||
elif ext == '.doc':
|
||||
return doc_to_markdown(content)
|
||||
|
||||
# ── .txt и прочие ──
|
||||
else:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ZIP-распаковка (без изменений)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
|
||||
|
||||
Не-ZIP файлы проходят без изменений.
|
||||
|
||||
Защита от ZIP-бомб:
|
||||
- Максимум 500 файлов в архиве
|
||||
- Ratio file_size/compress_size не более 100:1
|
||||
- Накопительный размер распакованных данных не более 500 MB
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
Новый список файлов (ZIP раскрыты, остальные как есть)
|
||||
"""
|
||||
# Лимиты защиты от ZIP-бомб (глобально на весь вызов)
|
||||
MAX_FILES_IN_ARCHIVE = 500
|
||||
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
|
||||
MAX_RATIO = 100
|
||||
|
||||
result: List[Tuple[str, bytes, str]] = []
|
||||
queue: List[Tuple[str, bytes, str]] = list(files)
|
||||
total_uncompressed = 0
|
||||
|
||||
def _decode_name(name: str, info) -> str:
|
||||
"""Декодировать имя из ZIP.
|
||||
|
||||
Если флаг UTF-8 (bit 11) выставлен — имя уже корректное (берём как есть).
|
||||
Иначе zipfile декодировал имя как CP437. Пытаемся восстановить исходные
|
||||
байты (encode cp437) и декодировать:
|
||||
1) как UTF-8 — если результат в кириллице/ASCII (Info-ZIP/Linux-зипы
|
||||
пишут UTF-8 без флага; случайная коллизия CP866→UTF-8 отсекается
|
||||
проверкой диапазона);
|
||||
2) как CP866 — реальные 1С-выгрузки.
|
||||
"""
|
||||
def _cyr_ok(s: str) -> bool:
|
||||
# Все символы — ASCII или кириллица (U+0400–U+04FF)
|
||||
return all(ord(c) < 128 or 0x0400 <= ord(c) <= 0x04FF for c in s)
|
||||
|
||||
if not (info.flag_bits & 0x800) and any(ord(c) > 127 for c in name):
|
||||
try:
|
||||
raw = name.encode("cp437")
|
||||
except UnicodeEncodeError:
|
||||
return name
|
||||
# 1) UTF-8 (Info-ZIP/Linux без флага)
|
||||
try:
|
||||
dec = raw.decode("utf-8")
|
||||
if _cyr_ok(dec):
|
||||
return dec
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
# 2) CP866 (1С)
|
||||
try:
|
||||
return raw.decode("cp866")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return name
|
||||
|
||||
while queue:
|
||||
fname, content, ctype = queue.pop(0)
|
||||
|
||||
if not fname.lower().endswith('.zip'):
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
infos = [i for i in zf.infolist() if not i.is_dir()]
|
||||
|
||||
if len(infos) > MAX_FILES_IN_ARCHIVE:
|
||||
log.warning("ZIP too many files, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
# Проверяем лимиты ДО чтения содержимого (защита от бомб)
|
||||
bomb = False
|
||||
for info in infos:
|
||||
if info.compress_size > 0:
|
||||
ratio = info.file_size / info.compress_size
|
||||
if ratio > MAX_RATIO:
|
||||
bomb = True
|
||||
break
|
||||
if total_uncompressed + info.file_size > MAX_UNCOMPRESSED:
|
||||
bomb = True
|
||||
break
|
||||
|
||||
if bomb:
|
||||
log.warning("ZIP bomb/limit, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
for info in infos:
|
||||
name = _decode_name(info.filename, info)
|
||||
name = os.path.basename(name)
|
||||
if not name:
|
||||
continue
|
||||
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
|
||||
low = name.lower()
|
||||
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))):
|
||||
continue
|
||||
|
||||
inner_data = zf.read(info)
|
||||
total_uncompressed += len(inner_data)
|
||||
if total_uncompressed > MAX_UNCOMPRESSED:
|
||||
log.warning("ZIP uncompressed limit exceeded (mid-read): %s", fname)
|
||||
break
|
||||
|
||||
# Вложенные ZIP добавляем в очередь на повторную распаковку
|
||||
queue.append((name, inner_data, ""))
|
||||
|
||||
except Exception as e:
|
||||
log.warning("Failed to expand ZIP %s: %s", fname, e)
|
||||
result.append((fname, content, ctype))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
LLM-клиент для DrHider.
|
||||
|
||||
Используется в TwoPassObfuscator._scan_llm_ner() для обнаружения
|
||||
имён, компаний, адресов и паспортных данных через LLM API.
|
||||
|
||||
Интерфейс:
|
||||
client = LLMClient()
|
||||
result = client.complete(prompt) # str
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import httpx
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Клиент для вызова LLM API (aillm.ru / OpenAI-совместимый).
|
||||
|
||||
Использует переменные окружения:
|
||||
LLM_API_KEY или LLM_KEY — ключ API
|
||||
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
|
||||
LLM_MODEL — модель (по умолчанию gpt-oss-120b)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализировать клиент. httpx импортируется лениво.
|
||||
|
||||
Ведёт кумулятивные счётчики за всё время жизни клиента:
|
||||
tokens_prompt — суммарные входные токены
|
||||
tokens_completion — суммарные выходные токены
|
||||
llm_sec — суммарное время LLM-запросов (сек)
|
||||
|
||||
Плюс флаг текущей активности (для live-таймера в UI):
|
||||
llm_active — True пока идёт LLM-вызов
|
||||
llm_started — time.time() начала текущего LLM-вызова
|
||||
"""
|
||||
self._httpx = httpx
|
||||
self.tokens_prompt = 0
|
||||
self.tokens_completion = 0
|
||||
self.llm_sec = 0.0
|
||||
self.llm_active = False
|
||||
self.llm_started = 0.0
|
||||
|
||||
@property
|
||||
def tokens_total(self) -> int:
|
||||
return self.tokens_prompt + self.tokens_completion
|
||||
|
||||
def llm_elapsed_now(self) -> float:
|
||||
"""Секунд с начала текущего LLM-вызова (0, если LLM не активен)."""
|
||||
if self.llm_active:
|
||||
return time.time() - self.llm_started
|
||||
return 0.0
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
"""Отправить промпт в LLM и вернуть текст ответа.
|
||||
|
||||
Args:
|
||||
prompt: Текст промпта (инструкция + данные для анализа)
|
||||
|
||||
Returns:
|
||||
Текстовый ответ модели (обычно JSON-строка)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: при ошибке HTTP
|
||||
KeyError: при неожиданном формате ответа
|
||||
"""
|
||||
# Ключ API: сначала LLM_KEY, затем LLM_API_KEY (для совместимости)
|
||||
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||
|
||||
# URL и модель с значениями по умолчанию
|
||||
url = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||
model = os.environ.get("LLM_MODEL", "gpt-oss-120b")
|
||||
|
||||
# Пометим LLM-вызов как активный (для live-таймера в UI)
|
||||
self.llm_active = True
|
||||
self.llm_started = time.time()
|
||||
try:
|
||||
r = self._httpx.post(
|
||||
url,
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 8000,
|
||||
"temperature": 0.1,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# Время запроса (реальное время HTTP-вызова LLM)
|
||||
self.llm_sec += r.elapsed.total_seconds()
|
||||
|
||||
data = r.json()
|
||||
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси)
|
||||
usage = data.get("usage", {}) or {}
|
||||
self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0)
|
||||
self.tokens_completion += int(usage.get("completion_tokens", 0) or 0)
|
||||
|
||||
return data["choices"][0]["message"]["content"]
|
||||
finally:
|
||||
self.llm_active = False
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Оркестратор двухпроходной обфускации — класс TwoPassObfuscator.
|
||||
|
||||
Процесс:
|
||||
1. Проход 1 (сбор): извлечь текст → regex-сканирование → LLM-сканирование
|
||||
2. Проход 2 (замена): применить mapping ко всем файлам
|
||||
3. Сборка: ZIP + mapping.csv
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
from . import extractor
|
||||
from . import scanner
|
||||
from . import replacer
|
||||
from . import builder
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
def _dedupe_file_names(
|
||||
files: List[Tuple[str, bytes, str]]
|
||||
) -> List[Tuple[str, bytes, str]]:
|
||||
"""Уникализировать имена файлов перед обработкой.
|
||||
|
||||
Точный дубль (одинаковое имя + одинаковое содержимое) пропускается.
|
||||
При коллизии имени с ДРУГИМ содержимым добавляется суффикс _2, _3, ...
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
Новый список с уникальными именами (порядок сохранён).
|
||||
"""
|
||||
seen: Dict[str, bytes] = {}
|
||||
out: List[Tuple[str, bytes, str]] = []
|
||||
|
||||
for fname, content, ctype in files:
|
||||
candidate = fname
|
||||
if candidate in seen:
|
||||
if seen[candidate] == content:
|
||||
continue # точный дубль — пропускаем
|
||||
base, ext = os.path.splitext(fname)
|
||||
n = 2
|
||||
while f"{base}_{n}{ext}" in seen:
|
||||
n += 1
|
||||
candidate = f"{base}_{n}{ext}"
|
||||
seen[candidate] = content
|
||||
out.append((candidate, content, ctype))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class TwoPassObfuscator:
|
||||
"""Двухпроходный обфускатор документов.
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены ко всем файлам → ZIP с результатом.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах получает
|
||||
одно и то же фиктивное значение.
|
||||
|
||||
Attributes:
|
||||
_mapping: {оригинал: замена} — глобальный словарь
|
||||
_sorted_keys: ключи mapping, отсортированные по длине (убывание)
|
||||
_llm_client: опциональный LLM-клиент для NER
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client=None):
|
||||
"""Инициализировать обфускатор.
|
||||
|
||||
Args:
|
||||
llm_client: Объект с методом .complete(prompt) -> str.
|
||||
Если None — LLM-сканирование не выполняется.
|
||||
"""
|
||||
self._mapping: Dict[str, str] = {}
|
||||
self._sorted_keys: List[str] = []
|
||||
self._compiled_re = None # кэш скомпилированного regex (A2)
|
||||
self._counters: Dict[str, int] = {} # Глобальные счётчики токенов
|
||||
self._llm_client = llm_client
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Главная точка входа
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def obfuscate(
|
||||
self, files: List[Tuple[str, bytes, str]],
|
||||
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
|
||||
cancel_event=None, file_progress=None
|
||||
) -> Tuple[bytes, str, Optional[dict]]:
|
||||
"""Обфусцировать список файлов.
|
||||
|
||||
Все форматы → Markdown → замена → результат.
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
|
||||
где phase ∈ {"start", "done"}, idx — 0-based индекс,
|
||||
elapsed — время обработки конкретного файла (сек).
|
||||
cancel_event: threading.Event — мягкая остановка. Проверяется между
|
||||
файлами LLM и между файлами замены; текущий файл добирается.
|
||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
|
||||
События: extract_done{total_chars}, file_start{chars,chunks,idx},
|
||||
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string, meta):
|
||||
zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
|
||||
csv_string — содержимое mapping.csv как строка
|
||||
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
|
||||
при прерывании (частичный результат)
|
||||
"""
|
||||
# ── Предобработка: распаковать ZIP + уникализировать имена ──
|
||||
files = extractor.expand_zips(files)
|
||||
files = _dedupe_file_names(files)
|
||||
|
||||
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
|
||||
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
|
||||
llm_done: set = set() # файлы, чей LLM-анализ ПОЛНОСТЬЮ завершён
|
||||
|
||||
def _fp(event, fname, **fields):
|
||||
if event == "file_done":
|
||||
llm_done.add(fname)
|
||||
if file_progress:
|
||||
fields["idx"] = name_to_idx.get(fname)
|
||||
file_progress(event, fname, **fields)
|
||||
|
||||
try:
|
||||
# ── Проход 1: сбор сущностей ──
|
||||
# Извлекаем Markdown из каждого файла
|
||||
all_texts: Dict[str, str] = {}
|
||||
total = len(files)
|
||||
file_times: List[float] = [0.0] * total
|
||||
skipped: set = set() # имена файлов, которые не удалось обработать (битые)
|
||||
|
||||
# A1: .doc конвертируются через HTTP-сервис liberta ПОСЛЕДОВАТЕЛЬНО.
|
||||
# Без параллельности: liberta не выдерживает конкурентные вызовы
|
||||
# (libreoffice падает с javaldx).
|
||||
|
||||
cancelled_extract = False
|
||||
for i, (fname, content, ctype) in enumerate(files):
|
||||
# Мягкая остановка в фазе извлечения (между файлами) — не ждём все .doc
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
log.info("obfuscate: cancelled during extract, processed=%d/%d", i, total)
|
||||
cancelled_extract = True
|
||||
break
|
||||
display_name = os.path.basename(fname) or fname
|
||||
if progress_cb:
|
||||
progress_cb("start", i, total, display_name, 0.0)
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
text = extractor.extract_text(fname, content, ctype)
|
||||
except Exception as e:
|
||||
log.warning("obfuscate: skip file %r: %r", fname, e)
|
||||
skipped.add(fname)
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, 0.0)
|
||||
continue
|
||||
all_texts[fname] = text
|
||||
|
||||
# Regex-сканирование (быстрое, локальное)
|
||||
if text and not text.startswith("[DOC binary"):
|
||||
scanner.scan_regex(text, self._mapping, self._counters)
|
||||
file_times[i] += time.time() - t0
|
||||
|
||||
# Объём извлечённого текста — для оценки времени (глобальной ETA)
|
||||
total_chars = sum(len(t) for t in all_texts.values())
|
||||
if file_progress:
|
||||
file_progress("extract_done", None, total_chars=total_chars,
|
||||
per_file={fname: len(t) for fname, t in all_texts.items()})
|
||||
|
||||
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
||||
cancelled_llm = cancelled_extract
|
||||
if not cancelled_llm and self._llm_client:
|
||||
try:
|
||||
scanner.scan_llm_ner(
|
||||
all_texts, self._mapping, self._llm_client, self._counters,
|
||||
cancel_event=cancel_event, file_progress=_fp,
|
||||
)
|
||||
except scanner.CancelRequested:
|
||||
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
|
||||
cancelled_llm = True
|
||||
|
||||
# Предсортировать ключи один раз (по убыванию длины)
|
||||
self._sorted_keys = sorted(
|
||||
self._mapping.keys(), key=len, reverse=True
|
||||
)
|
||||
# A2: кэш скомпилированного regex — один на все файлы прохода 2
|
||||
self._compiled_re = replacer._build_combined_re(self._sorted_keys)
|
||||
|
||||
# ── Проход 2: замена сущностей ──
|
||||
cancelled = cancelled_llm
|
||||
results: List[Tuple[str, bytes]] = []
|
||||
|
||||
for i, (in_fname, content, ctype) in enumerate(files):
|
||||
fname = in_fname
|
||||
display_name = os.path.basename(in_fname) or in_fname
|
||||
|
||||
# Битый файл (не удалось извлечь текст) — пропускаем
|
||||
if fname in skipped:
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, 0.0)
|
||||
continue
|
||||
|
||||
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
|
||||
if cancelled_llm and fname not in llm_done:
|
||||
continue
|
||||
|
||||
# Мягкая остановка в фазе замены (между файлами).
|
||||
# НЕ проверяем, если LLM уже прерван: файлы из llm_done добираем (сохраняем всё готовое).
|
||||
if not cancelled_llm and cancel_event is not None and cancel_event.is_set():
|
||||
log.info("obfuscate: cancelled in replace phase, processed=%d", len(results))
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
t0 = time.time()
|
||||
obf_content = content # По умолчанию — без изменений
|
||||
|
||||
if fname.endswith('.doc'):
|
||||
# .doc — конвертируется через liberta, обрабатывается как .docx
|
||||
md_text = all_texts.get(fname, '')
|
||||
replaced = replacer.apply_replacements(
|
||||
md_text, self._mapping, self._sorted_keys, self._compiled_re
|
||||
)
|
||||
md_name = os.path.splitext(fname)[0] + '.md'
|
||||
obf_content = replaced.encode('utf-8')
|
||||
fname = md_name
|
||||
else:
|
||||
# Все форматы → Markdown → замена в тексте
|
||||
md_text = all_texts.get(fname, '')
|
||||
replaced = replacer.apply_replacements(
|
||||
md_text, self._mapping, self._sorted_keys, self._compiled_re
|
||||
)
|
||||
# Имя файла: заменить расширение на .md
|
||||
md_name = os.path.splitext(fname)[0] + '.md'
|
||||
obf_content = replaced.encode('utf-8')
|
||||
fname = md_name
|
||||
|
||||
file_times[i] += time.time() - t0
|
||||
results.append((fname, obf_content))
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, round(file_times[i], 2))
|
||||
|
||||
# ── Сборка результата ──
|
||||
csv_str = builder.build_mapping_csv(self._mapping)
|
||||
zip_data = builder.build_zip(results, csv_str)
|
||||
|
||||
meta = None
|
||||
if cancelled:
|
||||
expected_total = total - len(skipped)
|
||||
meta = {"cancelled": True, "processed": len(results), "total": expected_total}
|
||||
return zip_data, csv_str, meta
|
||||
|
||||
finally:
|
||||
# Очистка состояния (обфускатор может использоваться повторно)
|
||||
self._mapping.clear()
|
||||
self._sorted_keys.clear()
|
||||
self._compiled_re = None
|
||||
self._counters.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Удобная функция для быстрого вызова
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def obfuscate_files(
|
||||
files: List[Tuple[str, bytes, str]], llm_client=None,
|
||||
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
|
||||
cancel_event=None, file_progress=None
|
||||
) -> Tuple[bytes, str, Optional[dict]]:
|
||||
"""Обфусцировать список файлов — удобная функция.
|
||||
|
||||
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
llm_client: Опциональный LLM-клиент
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname)
|
||||
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
|
||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string, meta)
|
||||
"""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files, progress_cb=progress_cb,
|
||||
cancel_event=cancel_event, file_progress=file_progress)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Проход 2: замена сущностей в документах.
|
||||
|
||||
Два метода:
|
||||
1. apply_replacements — замена в Markdown/plain-тексте (основной)
|
||||
2. replace_in_docx — замена в DOCX с сохранением форматирования
|
||||
(для опционального DOCX-выхода, будет добавлен позже)
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def _md_tolerant_pattern(original: str) -> str:
|
||||
r"""Паттерн, допускающий ** между частями строки (Markdown bold).
|
||||
|
||||
DOCX → Markdown: жирность добавляет ** вокруг слов.
|
||||
Если ** попадает внутрь сущности (например, **ООО **"**НУБЕС**"),
|
||||
точное совпадение не срабатывает.
|
||||
|
||||
Этот паттерн вставляет (?:\*{1,2})? между каждым словом/символом,
|
||||
позволяя найти сущность даже с ** внутри.
|
||||
|
||||
Args:
|
||||
original: Исходная строка сущности (например, 'ООО "НУБЕС"')
|
||||
|
||||
Returns:
|
||||
Regex-паттерн, находящий ту же строку с ** между частями
|
||||
"""
|
||||
MD = r'(?:\*{1,2})?'
|
||||
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
|
||||
if len(parts) <= 1:
|
||||
return None # Одно слово — ** не может разорвать изнутри
|
||||
return MD + MD.join(re.escape(p) for p in parts) + MD
|
||||
|
||||
|
||||
def _build_combined_re(sorted_keys: List[str]):
|
||||
"""Построить единый regex из всех ключей (убывание длины, границы слова).
|
||||
|
||||
Возвращает None, если нет ни одного применимого ключа.
|
||||
"""
|
||||
alternatives = []
|
||||
for original in sorted_keys:
|
||||
if not original:
|
||||
continue
|
||||
esc = re.escape(original)
|
||||
# Границы слова — только если сущность обрамлена буквами/цифрами
|
||||
if original[0].isalnum() and original[-1].isalnum():
|
||||
alternatives.append(r'(?<!\w)' + esc + r'(?!\w)')
|
||||
else:
|
||||
alternatives.append(esc)
|
||||
|
||||
if not alternatives:
|
||||
return None
|
||||
return re.compile('|'.join(alternatives))
|
||||
|
||||
|
||||
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str],
|
||||
compiled_re=None) -> str:
|
||||
"""Применить все замены из словаря mapping к строке.
|
||||
|
||||
Ключи применяются в порядке убывания длины (sorted_keys).
|
||||
Это гарантирует, что более длинные совпадения заменяются раньше
|
||||
коротких (например, «ИНН 123456789012» до «ИНН 1234567890»).
|
||||
|
||||
Для сущностей, начинающихся и заканчивающихся на букву/цифру,
|
||||
используются границы слова (\\b), чтобы избежать частичных замен.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
mapping: Словарь {оригинал: замена}
|
||||
sorted_keys: Ключи mapping, отсортированные по длине (убывание)
|
||||
compiled_re: Опционально, заранее скомпилированный regex из _build_combined_re
|
||||
(кэш между файлами). Если None — компилируется здесь.
|
||||
|
||||
Returns:
|
||||
Текст с заменами
|
||||
"""
|
||||
result = text
|
||||
|
||||
# ── Однопроходная замена: единый regex из всех ключей ──
|
||||
# Вместо N отдельных re.sub по всему тексту (O(N×text)) собираем
|
||||
# один паттерн из всех ключей и делаем один re.sub с callback (O(text)).
|
||||
if not sorted_keys:
|
||||
return result
|
||||
|
||||
combined = compiled_re if compiled_re is not None else _build_combined_re(sorted_keys)
|
||||
if combined is None:
|
||||
return result
|
||||
|
||||
matched_keys = set()
|
||||
|
||||
def _replace(match):
|
||||
m = match.group(0)
|
||||
matched_keys.add(m)
|
||||
return mapping.get(m, m)
|
||||
|
||||
result = combined.sub(_replace, result)
|
||||
|
||||
# ── Фоллбэк: Markdown-жирность могла разорвать строку ──
|
||||
# Применяется только к ключам, которые основная замена НЕ нашла
|
||||
# (например, «ООО **"**НУБЕС**"» — ** внутри сущности).
|
||||
for original in sorted_keys:
|
||||
if not original or original in matched_keys:
|
||||
continue
|
||||
md_pattern = _md_tolerant_pattern(original)
|
||||
if md_pattern is not None:
|
||||
result = re.sub(md_pattern, mapping[original], result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
|
||||
"""Заменить сущности в DOCX-документе с сохранением форматирования.
|
||||
|
||||
Алгоритм для каждого параграфа:
|
||||
1. Склеиваем текст всех runs в одну строку
|
||||
2. Применяем замены
|
||||
3. Пишем результат в первый run, очищаем остальные
|
||||
|
||||
Это нужно потому что python-docx разбивает текст на runs
|
||||
(например, mid-docx форматирование), и сущность может быть
|
||||
разорвана между несколькими runs.
|
||||
|
||||
Args:
|
||||
doc: Объект python-docx Document
|
||||
mapping: Словарь замен
|
||||
sorted_keys: Ключи mapping по убыванию длины
|
||||
|
||||
Returns:
|
||||
Бинарное содержимое изменённого DOCX-файла
|
||||
"""
|
||||
# ── Обработка параграфов ──
|
||||
for para in doc.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
# Склеиваем все runs в одну строку
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = apply_replacements(full_text, mapping, sorted_keys)
|
||||
|
||||
# Если были замены — пишем в первый run, очищаем остальные
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
# ── Обработка таблиц ──
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = apply_replacements(full_text, mapping, sorted_keys)
|
||||
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
# Сохраняем в буфер
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Проход 1: обнаружение сущностей в тексте документов.
|
||||
|
||||
Два метода сканирования:
|
||||
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
|
||||
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
|
||||
|
||||
Замена: гибридные токены — читаемый шаблон + уникальный номер.
|
||||
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import (
|
||||
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
|
||||
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TYPE_POOLS = {
|
||||
"person_name": (RU_SURNAMES, "person"),
|
||||
"person": (RU_SURNAMES, "person"),
|
||||
"company": (COMPANY_NOUNS, "company"),
|
||||
"phone": (["+7_000_000"], "phone"),
|
||||
"email": (["email"], "email"),
|
||||
"address": (CITY_POOL, "address"),
|
||||
"inn": (["ИНН"], "inn"),
|
||||
"inn_ul": (["ИНН"], "inn"),
|
||||
"inn_fl": (["ИНН"], "inn"),
|
||||
"kpp": (["КПП"], "kpp"),
|
||||
"ogrn": (["ОГРН"], "ogrn"),
|
||||
"bik": (["БИК"], "bik"),
|
||||
"passport": (["Паспорт"], "passport"),
|
||||
"bank_account": (["Счёт"], "bank"),
|
||||
"rs": (["РС"], "bank"),
|
||||
"ks": (["КС"], "bank"),
|
||||
"contract_number": (["Договор"], "contract"),
|
||||
"other_id": (["ID"], "other"),
|
||||
}
|
||||
_FALLBACK_POOL = (["Данные"], "data")
|
||||
|
||||
|
||||
def _next_token(entity_type: str, counters: Dict[str, int]) -> str:
|
||||
"""Сгенерировать токен замены: случайный шаблон из пула + номер.
|
||||
|
||||
Одна и та же сущность в mapping получает один и тот же токен
|
||||
(mapping dict гарантирует согласованность).
|
||||
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
|
||||
|
||||
Args:
|
||||
entity_type: Тип сущности (person_name, company, phone...)
|
||||
counters: Глобальные счётчики {prefix_key: count}
|
||||
|
||||
Returns:
|
||||
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
|
||||
"""
|
||||
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
|
||||
template = random.choice(pool)
|
||||
key = f"{prefix_key}_{template}"
|
||||
counters[key] = counters.get(key, 0) + 1
|
||||
return f"{template}_{counters[key]:04d}"
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен.
|
||||
|
||||
Замена: гибридные токены (шаблон + номер).
|
||||
|
||||
Args:
|
||||
text: Текст документа для сканирования
|
||||
mapping: Словарь замен (мутабельный, пополняется)
|
||||
counters: Глобальные счётчики токенов (мутабельный)
|
||||
"""
|
||||
# ── Сущности по regex-паттернам ──
|
||||
for entity_type, pattern in ENTITY_PATTERNS.items():
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token(entity_type, counters)
|
||||
|
||||
# ── Названия компаний ──
|
||||
for match in COMPANY_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token("company", counters)
|
||||
|
||||
# ── ФИО ──
|
||||
for match in PERSON_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token("person_name", counters)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LLM-NER: пофайловый чанкинг (Sonnet-схема)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class CancelRequested(Exception):
|
||||
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
|
||||
|
||||
# Приоритет границ для чанков (от предпочтительных к жёстким)
|
||||
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
|
||||
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
|
||||
_CHUNK_OVERLAP = 500 # перекрытие чанков (сущности на стыке)
|
||||
_LLM_CONCURRENCY = 1 # параллельность убрана — всё последовательно (liberta/LLM не тянет конкурентность)
|
||||
|
||||
|
||||
def split_into_chunks(text: str, size: int = _CHUNK_SIZE,
|
||||
overlap: int = _CHUNK_OVERLAP) -> List[str]:
|
||||
"""Разбить текст на чанки ~size символов с перекрытием overlap.
|
||||
|
||||
Режем по границам (\n\n, \n, ..., пробел) — чтобы не разрывать слова/сущности.
|
||||
"""
|
||||
chunks: List[str] = []
|
||||
start = 0
|
||||
tl = len(text)
|
||||
while start < tl:
|
||||
end = min(start + size, tl)
|
||||
if end == tl:
|
||||
chunks.append(text[start:])
|
||||
break
|
||||
cut = None
|
||||
for boundary in _CHUNK_BOUNDARIES:
|
||||
pos = text.rfind(boundary, start + size // 2, end)
|
||||
if pos != -1:
|
||||
cut = pos + len(boundary)
|
||||
break
|
||||
if cut is None:
|
||||
cut = end # жёсткий разрез — нет ни одного пробела/границы
|
||||
chunks.append(text[start:cut])
|
||||
# overlap: отступить назад, найти начало слова
|
||||
ov_start = max(start, cut - overlap)
|
||||
space = text.find(' ', ov_start)
|
||||
start = (space + 1) if (space != -1 and space < cut) else ov_start
|
||||
return chunks
|
||||
|
||||
|
||||
def normalize_entity(s: str) -> str:
|
||||
"""Нормализовать строку сущности для надёжного дедупа (регистр/пробелы/тире)."""
|
||||
s = s.strip()
|
||||
s = re.sub(r'\s+', ' ', s)
|
||||
s = s.lower()
|
||||
s = re.sub(r'[«»\u201c\u201d\u2018\u2019"\u0027]', '"', s)
|
||||
s = re.sub(r'[\u2014\u2013\u2212-]', '-', s)
|
||||
s = s.replace('\u00ad', '') # мягкий перенос
|
||||
s = s.replace('ё', 'е') # Ё→Е (OCR/PDF)
|
||||
return s
|
||||
|
||||
|
||||
def _verify_entity(value: str, full_text: str) -> bool:
|
||||
"""Верификация: сущность реально есть в полном тексте файла (отсекает галлюцинации)."""
|
||||
if value in full_text:
|
||||
return True
|
||||
return normalize_entity(value) in normalize_entity(full_text)
|
||||
|
||||
|
||||
def _build_llm_prompt(text: str, already_found: List[str]) -> str:
|
||||
"""Собрать промпт для одного текстового фрагмента (чанк/файл)."""
|
||||
af = "\n".join(f"- {k}" for k in already_found) if already_found else "(none)"
|
||||
return (
|
||||
"You are a PII (Personally Identifiable Information) detector for Russian legal documents.\n\n"
|
||||
"Find ALL sensitive or private information that is NOT already captured below.\n\n"
|
||||
|
||||
"Already captured by regex (skip these exact strings):\n"
|
||||
f"{af}\n\n"
|
||||
|
||||
"Return a JSON array. Each item must have:\n"
|
||||
' - "type": MUST be one of: person_name, company, phone, email, address,\n'
|
||||
' inn, kpp, ogrn, bik, passport, bank_account, contract_number, other_id\n'
|
||||
' Use "other_id" only if nothing else fits. If unsure — do NOT include.\n'
|
||||
' - "value": the EXACT string from the text (copy verbatim)\n\n'
|
||||
|
||||
"NOT PII — do NOT include these (legal terms, not private data):\n"
|
||||
"- Roles: Исполнитель, Заказчик, Стороны, Сторона, Покупатель, Поставщик\n"
|
||||
"- Titles: Генеральный директор, Директор, действующего на основании\n"
|
||||
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n\n"
|
||||
|
||||
"Rules:\n"
|
||||
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
|
||||
"- Same value → include only once\n"
|
||||
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
|
||||
|
||||
f"Text:\n{text}"
|
||||
)
|
||||
|
||||
|
||||
def _call_llm(text: str, llm_client) -> List[dict]:
|
||||
"""Один вызов LLM по фрагменту → список сущностей (пустой при ошибке)."""
|
||||
try:
|
||||
raw = llm_client.complete(text)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
entities = json.loads(raw)
|
||||
if not isinstance(entities, list):
|
||||
return []
|
||||
return entities
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
llm_client, counters: Dict[str, int],
|
||||
cancel_event=None, file_progress=None) -> None:
|
||||
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
|
||||
|
||||
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
|
||||
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
|
||||
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
|
||||
текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
|
||||
|
||||
Args:
|
||||
all_texts: {filename: text_content} — тексты всех файлов
|
||||
mapping: Словарь замен (мутабельный, пополняется)
|
||||
llm_client: Объект с методом .complete(prompt) -> str
|
||||
counters: Глобальные счётчики токенов (мутабельный)
|
||||
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
|
||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
||||
"""
|
||||
already_found = list(mapping.keys())
|
||||
|
||||
# Порядок файлов: крупные вперёд (приоритет на долгую обработку)
|
||||
file_items = sorted(
|
||||
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
|
||||
)
|
||||
|
||||
for fname, full_text in file_items:
|
||||
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise CancelRequested()
|
||||
|
||||
if not full_text or full_text.startswith("[DOC binary"):
|
||||
continue
|
||||
chunks = split_into_chunks(full_text)
|
||||
|
||||
if file_progress:
|
||||
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# Параллельные вызовы LLM по чанкам этого файла
|
||||
if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
|
||||
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
|
||||
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
|
||||
for c in chunks]
|
||||
all_ent = []
|
||||
for fut in futures:
|
||||
all_ent.extend(fut.result())
|
||||
else:
|
||||
all_ent = []
|
||||
for k, c in enumerate(chunks):
|
||||
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
|
||||
if file_progress:
|
||||
file_progress("file_chunk", fname,
|
||||
chunks_done=k + 1, chunks_total=len(chunks))
|
||||
|
||||
# Дедуп + верификация + добавление в mapping
|
||||
seen: set = set()
|
||||
for ent in all_ent:
|
||||
val = ent.get("value", "").strip()
|
||||
if not val or val in mapping:
|
||||
continue
|
||||
nk = normalize_entity(val)
|
||||
if nk in seen:
|
||||
continue
|
||||
seen.add(nk)
|
||||
if not _verify_entity(val, full_text):
|
||||
log.debug("LLM hallucination dropped: %r", val)
|
||||
continue
|
||||
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
|
||||
mapping[val] = _next_token(ent_type, counters)
|
||||
|
||||
if file_progress:
|
||||
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
|
||||
Reference in New Issue
Block a user