Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4cc3ccbc |
@@ -0,0 +1,48 @@
|
||||
# ⛔ ПРАВИЛА ДЛЯ COPILOT — DRHIDER
|
||||
|
||||
Читать перед ЛЮБЫМ действием.
|
||||
|
||||
---
|
||||
|
||||
## ⛔⛔⛔ НИЧЕГО НЕ ДЕЛАТЬ БЕЗ «ДЕЛАЙ»
|
||||
|
||||
Любой вопрос, обсуждение, анализ — **НЕ повод менять код.**
|
||||
Только после явного: «делай», «делайте», «давай», «приступай», «пушить», «commit», «push».
|
||||
|
||||
## ⛔⛔⛔ НЕ МЕНЯТЬ КОД БЕЗ РАЗРЕШЕНИЯ
|
||||
|
||||
Даже если ошибка очевидна. **Показать → описать → ЖДАТЬ.**
|
||||
|
||||
## ⛔⛔⛔ НЕ СПЕШИТЬ
|
||||
|
||||
Обдумать. Проверить. Перепроверить. Только потом отвечать.
|
||||
|
||||
## ⛔⛔⛔ НЕ ПРЕДПОЛАГАТЬ, НЕ ДОГАДЫВАТЬСЯ
|
||||
|
||||
Сомневаешься — **ОСТАНОВИСЬ И СПРОСИ.**
|
||||
|
||||
## ⛔⛔⛔ НЕ ЛЕЗТЬ В ЛОКАЛЬНЫЕ ПАПКИ CONTRACTS
|
||||
|
||||
Код drhider — на ВМ (`5.172.178.213` через SSH). Исключение: `/home/naeel/nubes/loadtest/`.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Что есть в проекте
|
||||
|
||||
| Что нужно | Читай |
|
||||
|---|---|
|
||||
| Меняешь код? | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — полный порядок действий |
|
||||
| Архитектура? | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — схема, модули, поток данных |
|
||||
| Деплой/ВМ/env? | [`docs/DEPLOY.md`](docs/DEPLOY.md) — всё про окружение |
|
||||
| Обзор проекта? | [`README.md`](README.md) — структура, API, правила |
|
||||
| История изменений? | [`History/`](History/) — что когда и зачем делалось |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Быстрая справка
|
||||
|
||||
- Платформа: **Штурвал** (Managed Flask, без Dockerfile/gunicorn)
|
||||
- URL: https://drhider.pythonk8s.dev.nubes.ru/
|
||||
- БД: **нет**, всё в памяти
|
||||
- Дизайн фронтенда: `/home/naeel/nubes/design/`
|
||||
- ВМ (legacy): `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
|
||||
-32
@@ -1,36 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
instance/
|
||||
.flaskenv
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
*.log
|
||||
build/
|
||||
dist/
|
||||
!dist/
|
||||
!dist/file-picker.esm.js
|
||||
!dist/file-picker.iife.js
|
||||
*.egg-info/
|
||||
# TMP/
|
||||
# about1500.md
|
||||
*.har
|
||||
chrome-net-export-log.json
|
||||
History/tests/2026-09-15-*.md
|
||||
tests/e2e_mass_processing.py
|
||||
TSTFILES/
|
||||
files/
|
||||
upload-platform/
|
||||
OBFUSCATOR/
|
||||
.agent-tmp/
|
||||
/chrome-net-export-log.json
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libreoffice-writer \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY site /app/site
|
||||
COPY drhider /app/drhider
|
||||
EXPOSE 5000
|
||||
CMD ["python", "site/app.py"]
|
||||
@@ -0,0 +1,43 @@
|
||||
# DrHider — Managed Flask на pythonk8s
|
||||
|
||||
**Дата:** 2026-07-11
|
||||
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
|
||||
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
drhider/
|
||||
├── Dockerfile # python:3.12-slim, gunicorn + gevent :5000
|
||||
├── requirements.txt # flask, gunicorn, gevent, python-docx, pdfplumber, httpx
|
||||
├── .gitignore
|
||||
├── .gitea/workflows/deploy.yaml # CI (linux_amd64)
|
||||
├── drhider.py # ядро обфускации (скопировано с ВМ contracts)
|
||||
├── drhider_server.py # HTTP-сервер (скопировано с ВМ contracts)
|
||||
├── site/
|
||||
│ ├── app.py # Flask: GET /, POST /api/drhider, /health
|
||||
│ ├── templates/index.html
|
||||
│ └── static/favicon.svg
|
||||
└── History/
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | / | Веб-интерфейс |
|
||||
| GET | /health | `{"ok": true, "version": "..."}` |
|
||||
| POST | /api/drhider | multipart files → ZIP с обфусцированными документами |
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Назначение | По умолчанию |
|
||||
|---|---|---|
|
||||
| `LLM_API_KEY` / `LLM_KEY` | Ключ API для LLM | — |
|
||||
| `LLM_URL` | URL LLM API | `https://api.aillm.ru/v1/chat/completions` |
|
||||
| `LLM_MODEL` | Модель | `gpt-oss-120b` |
|
||||
|
||||
## Коммиты
|
||||
|
||||
- `a455f9d` — Начальная структура managed Flask + drhider
|
||||
- `a89df86` — Managed Flask: app.py, index.html, CI workflow
|
||||
@@ -0,0 +1,145 @@
|
||||
# DrHider — Полный аудит и план рефакторинга
|
||||
|
||||
**Дата:** 2026-07-12
|
||||
**Платформа:** Штурвал (Managed Flask) — запускает Flask сам, без Dockerfile/gunicorn
|
||||
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
|
||||
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
|
||||
|
||||
---
|
||||
|
||||
## Текущее состояние (аудит каждого файла)
|
||||
|
||||
| Файл | Статус | Решение |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | ❌ НЕ НУЖЕН | Штурвал сам запускает Flask. Удалить. |
|
||||
| `drhider_server.py` | ❌ НЕ НУЖЕН | Standalone-сервер с ВМ (:8767). Flask app.py уже обрабатывает /api/drhider. Удалить. |
|
||||
| `requirements.txt` | ⚠️ Нужны правки | Убрать `gunicorn` и `gevent`. Оставить `flask`, `python-docx`, `pdfplumber`, `httpx`. |
|
||||
| `.gitignore` | ✅ OK | Без изменений. |
|
||||
| `.gitea/workflows/deploy.yaml` | ✅ OK | CI для Штурвала. |
|
||||
| `site/app.py` | ⚠️ Нужны правки | Удалить блок `if __name__ == "__main__"` (gunicorn). При рефакторинге — вынести LLMClient. |
|
||||
| `site/templates/index.html` | ✅ OK | UI. |
|
||||
| `site/static/favicon.svg` | ✅ OK | Иконка (из loadtest). |
|
||||
| `drhider.py` | ⚠️ МОНОЛИТ | 560 строк. Разбить на ~20 модулей. |
|
||||
| `History/` | ✅ OK | Документация. |
|
||||
|
||||
---
|
||||
|
||||
## План рефакторинга (Фаза 1: удаление лишнего)
|
||||
|
||||
### Шаг 1.1 — Удалить `Dockerfile`
|
||||
### Шаг 1.2 — Удалить `drhider_server.py`
|
||||
### Шаг 1.3 — Исправить `requirements.txt` (убрать gunicorn, gevent)
|
||||
### Шаг 1.4 — Исправить `site/app.py` (убрать блок `if __name__ == "__main__"`)
|
||||
### Шаг 1.5 — Проверить синтаксис → commit → push
|
||||
|
||||
---
|
||||
|
||||
## План рефакторинга (Фаза 2: модульная структура)
|
||||
|
||||
### Новая структура `drhider/` пакета (вместо `drhider.py` 560 строк):
|
||||
|
||||
```
|
||||
drhider/ # Пакет ядра обфускации
|
||||
├── __init__.py # re-export: obfuscate_files()
|
||||
├── config.py # Константы: ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
|
||||
│ # RU_SURNAMES, RU_NAMES, RU_PATRONYMICS,
|
||||
│ # RU_CITIES, RU_STREETS, FAKE_DOMAINS
|
||||
├── checksum.py # _checksum_inn10, _checksum_inn12, _checksum_ogrn
|
||||
├── random_utils.py # _random_digits, _random_letters
|
||||
├── generators/ # Генераторы фиктивных значений
|
||||
│ ├── __init__.py # ENTITY_GENERATORS маппинг
|
||||
│ ├── phone.py # generate_phone
|
||||
│ ├── email.py # generate_email
|
||||
│ ├── inn.py # generate_inn10, generate_inn12
|
||||
│ ├── ogrn.py # generate_ogrn
|
||||
│ ├── kpp.py # generate_kpp
|
||||
│ ├── bik.py # generate_bik
|
||||
│ ├── accounts.py # generate_rs, generate_ks
|
||||
│ ├── passport.py # generate_passport
|
||||
│ ├── company.py # generate_company
|
||||
│ ├── person.py # generate_person
|
||||
│ └── address.py # generate_address
|
||||
├── extractor.py # _extract_text, _expand_zips, _convert_pdfs_to_docx
|
||||
├── scanner.py # Проход 1: _scan_regex, _scan_llm_ner
|
||||
├── replacer.py # Проход 2: _replace_in_docx, _replace_in_text, _apply_replacements
|
||||
├── builder.py # Сборка: _build_zip, _build_mapping_csv
|
||||
├── obfuscator.py # TwoPassObfuscator — оркестратор
|
||||
└── llm_client.py # LLMClient (из app.py → сюда)
|
||||
|
||||
site/ # Flask-приложение
|
||||
├── __init__.py
|
||||
├── app.py # Только create_app() + регистрация blueprint'ов
|
||||
├── routes/
|
||||
│ ├── __init__.py # Регистрация всех blueprint'ов
|
||||
│ ├── main_bp.py # GET /
|
||||
│ ├── health_bp.py # GET /health
|
||||
│ └── api_bp.py # POST /api/drhider
|
||||
├── templates/index.html
|
||||
└── static/favicon.svg
|
||||
```
|
||||
|
||||
### Принципы:
|
||||
- Каждый файл ≤ 50 строк (где возможно)
|
||||
- Каждый файл — одна ответственность
|
||||
- Максимум docstring и инлайн-комментариев
|
||||
- Все импорты явные, никаких `import *`
|
||||
|
||||
---
|
||||
|
||||
## Результат рефакторинга (2026-07-12)
|
||||
|
||||
### Итоговая структура
|
||||
|
||||
```
|
||||
drhider/ # Пакет ядра обфускации
|
||||
├── __init__.py # re-export: obfuscate_files, LLMClient, TwoPassObfuscator
|
||||
├── config.py # Константы: ENTITY_PATTERNS, словари имён/городов/улиц
|
||||
├── checksum.py # Контрольные суммы: ИНН10, ИНН12, ОГРН
|
||||
├── random_utils.py # Утилиты: random_digits, random_letters
|
||||
├── llm_client.py # LLMClient — HTTP-клиент к LLM API
|
||||
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
|
||||
├── scanner.py # Проход 1: scan_regex, scan_llm_ner
|
||||
├── replacer.py # Проход 2: apply_replacements, replace_in_docx, replace_in_text
|
||||
├── builder.py # Сборка: build_zip, build_mapping_csv
|
||||
├── obfuscator.py # TwoPassObfuscator — оркестратор
|
||||
└── generators/ # Генераторы фиктивных значений
|
||||
├── __init__.py # ENTITY_GENERATORS маппинг
|
||||
├── phone.py, email.py # Телефон, email
|
||||
├── inn.py, ogrn.py, kpp.py # ИНН, ОГРН, КПП
|
||||
├── bik.py, accounts.py # БИК, счета (р/с, к/с)
|
||||
├── passport.py # Паспорт
|
||||
├── company.py, person.py # Компания, ФИО
|
||||
└── address.py # Адрес
|
||||
|
||||
site/ # Flask-приложение
|
||||
├── app.py # create_app() + VERSION (20 строк)
|
||||
├── routes/
|
||||
│ ├── __init__.py # register_routes()
|
||||
│ ├── main_bp.py # GET /
|
||||
│ ├── health_bp.py # GET /health
|
||||
│ └── api_bp.py # POST /api/drhider
|
||||
├── templates/index.html
|
||||
└── static/favicon.svg
|
||||
```
|
||||
|
||||
### Что изменилось
|
||||
|
||||
| Было | Стало |
|
||||
|---|---|
|
||||
| `drhider.py` — 560 строк монолит | Пакет `drhider/` — 22 модуля по 20–100 строк |
|
||||
| `site/app.py` — 100 строк (всё в одном файле) | `app.py` 33 строки + 3 blueprint'а по 20–50 строк |
|
||||
| `Dockerfile` | Удалён (Штурвал сам) |
|
||||
| `drhider_server.py` | Удалён (Flask заменяет) |
|
||||
|
||||
### Коммиты
|
||||
|
||||
- `a2f754a` — Фаза 1: удалён Dockerfile, drhider_server.py, gunicorn
|
||||
- `37581eb` — Фаза 2: config.py, checksum.py, random_utils.py, generators/
|
||||
- `2cf4e08` — Фаза 2: llm_client.py, extractor.py, scanner.py, replacer.py, builder.py, obfuscator.py
|
||||
- `51bc1a7` — Фаза 2: site/app.py → blueprint'ы, старый drhider.py удалён
|
||||
|
||||
| Переменная | Значение |
|
||||
|---|---|
|
||||
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
|
||||
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
|
||||
| `LLM_MODEL` | `gpt-oss-120b` |
|
||||
@@ -0,0 +1,24 @@
|
||||
# Документация — 2026-07-12
|
||||
|
||||
**После рефакторинга** создана полная документация:
|
||||
|
||||
### Созданные файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `README.md` | Обзор проекта, правила для агентов, структура, API, env vars |
|
||||
| `ARCHITECTURE.md` | Архитектура: схема, двухпроходная обфускация, поток данных, описание каждого модуля |
|
||||
| `DEPLOY.md` | Деплой, ВМ, переменные окружения, локальная разработка, как добавить генератор |
|
||||
| `.github/copilot-instructions.md` | Правила для Copilot (не менять без «делай», не смотреть локальные папки, коммитить после каждой правки) |
|
||||
|
||||
### Ключевые факты зафиксированы
|
||||
|
||||
- Платформа: Штурвал (Managed Flask), без Dockerfile/gunicorn
|
||||
- URL: https://drhider.pythonk8s.dev.nubes.ru/
|
||||
- Дизайн фронтенда: `/home/naeel/nubes/design/`
|
||||
- ВМ (legacy): `5.172.178.213`, ssh-ключ `~/.ssh/naeel_vm_id_ed25519`
|
||||
- Переменные окружения: LLM_API_KEY, LLM_URL, LLM_MODEL
|
||||
|
||||
### Коммит
|
||||
|
||||
`0707d53` — Документация: README.md, ARCHITECTURE.md, DEPLOY.md, .github/copilot-instructions.md
|
||||
@@ -0,0 +1,76 @@
|
||||
# Запрос к Соннету — универсальная архитектура DrHider
|
||||
|
||||
## Контекст
|
||||
|
||||
Проект DrHider — обфускация документов (замена персональных данных на фиктивные). Сейчас:
|
||||
- Python/Flask, без БД, Managed Flask на платформе Штурвал
|
||||
- Двухпроходная архитектура: сбор сущностей → замена
|
||||
- Проход 1: regex-паттерны (телефон, email, ИНН, ОГРН, КПП, БИК, счета, паспорт) + LLM NER (ФИО, компании, адреса, паспорта)
|
||||
- Проход 2: замена по словарю mapping
|
||||
|
||||
## Проблема
|
||||
|
||||
Архитектура **в корне неверна** — жёстко привязана к фиксированному списку типов сущностей:
|
||||
|
||||
```python
|
||||
# config.py — жёстко зашитые паттерны
|
||||
ENTITY_PATTERNS = {
|
||||
"phone": r'...',
|
||||
"email": r'...',
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# scanner.py — жёстко зашитый промпт
|
||||
prompt = (
|
||||
"Найди ВСЕ следующие сущности:\n"
|
||||
"1. ФИО\n2. Компании\n3. Адреса\n4. Паспортные данные\n"
|
||||
)
|
||||
```
|
||||
|
||||
Если новый документ содержит СНИЛС, водительское удостоверение, номер договора, ИНН без префикса «ИНН» — система их НЕ обнаружит. Надо править config, generators, scanner, маппинги.
|
||||
|
||||
## Что нужно
|
||||
|
||||
**Универсальная архитектура**, где система САМА определяет что скрывать в любом документе:
|
||||
1. Не привязана к списку типов
|
||||
2. Не требует добавления паттернов под каждый новый вид данных
|
||||
3. LLM сама решает что является персональными/конфиденциальными данными
|
||||
4. Генерация фиктивных значений — тоже универсальная (не 11 отдельных функций)
|
||||
|
||||
## Текущая структура (полная)
|
||||
|
||||
```
|
||||
drhider/
|
||||
├── config.py # ENTITY_PATTERNS (10 regex), словари имён/городов
|
||||
├── checksum.py # Контрольные суммы ИНН/ОГРН
|
||||
├── random_utils.py # random_digits, random_letters
|
||||
├── generators/ # 11 файлов: phone, email, inn, ogrn, kpp, bik, accounts, passport, company, person, address
|
||||
├── llm_client.py # HTTP-клиент к LLM API
|
||||
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
|
||||
├── scanner.py # scan_regex (regex) + scan_llm_ner (LLM NER с жёстким промптом)
|
||||
├── replacer.py # apply_replacements, replace_in_docx, replace_in_text
|
||||
├── builder.py # build_zip, build_mapping_csv
|
||||
├── obfuscator.py # TwoPassObfuscator — оркестратор
|
||||
├── site/app.py # Flask (3 blueprint'а)
|
||||
└── site/templates/ # HTML-интерфейс
|
||||
```
|
||||
|
||||
## Вопросы к Соннету
|
||||
|
||||
1. Как перестроить архитектуру чтобы обнаружение было универсальным (LLM сама решает что скрывать)?
|
||||
2. Нужен ли regex вообще или достаточно одного LLM с правильным промптом?
|
||||
3. Как сделать генерацию фиктивных значений универсальной? (Не 11 функций под каждый тип, а что-то общее)
|
||||
4. Двухпроходная схема (сбор→замена) — сохранять или перейти на однопроходную (LLM сразу возвращает обфусцированный текст)?
|
||||
5. Как должен выглядеть промпт чтобы LLM возвращала структурированный результат (что найдено + на что заменить)?
|
||||
6. Стоит ли сохранять DOCX-форматирование (сейчас runs склеиваются-разделяются) или проще отдать LLM plain text?
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Документы до 200 MB
|
||||
- Форматы: .docx, .pdf, .txt, .zip
|
||||
- LLM: OpenAI-совместимое API (aillm.ru, модель gpt-oss-120b, 8000 токенов)
|
||||
- Без БД, всё в памяти
|
||||
- Платформа: Managed Flask на Kubernetes
|
||||
@@ -0,0 +1,78 @@
|
||||
# Ответ Соннета — универсальная архитектура DrHider (2026-07-12)
|
||||
|
||||
Кратко: 6 вопросов → 6 ответов → карта изменений.
|
||||
|
||||
---
|
||||
|
||||
## Q1. Как сделать обнаружение универсальным?
|
||||
|
||||
Промпт должен звучать не «найди вот эти типы», а «найди ВСЁ, что выглядит как приватная информация, и сам назови тип».
|
||||
|
||||
Затрагивает: `scanner.py` (промпт), `builder.py` (тип для mapping.csv).
|
||||
|
||||
---
|
||||
|
||||
## Q2. Regex или LLM?
|
||||
|
||||
**Гибрид — правильный выбор:**
|
||||
- **Regex = pre-pass** для структурированных данных (телефон, email, ИНН, БИК). Экономит токены LLM.
|
||||
- **LLM = post-pass** для неструктурированного (имена, адреса, нестандартные ID).
|
||||
- Дублирования не страшны — mapping dict сам отсеет.
|
||||
|
||||
---
|
||||
|
||||
## Q3. Генерация фиктивных значений — универсальная?
|
||||
|
||||
Два уровня:
|
||||
1. **Известные типы** — специализированные генераторы (оставить как есть).
|
||||
2. **Неизвестные типы** — fallback-генератор: character-class preserving замена (цифры→цифры, буквы→буквы той же длины).
|
||||
|
||||
Дополнительно: в промпт добавить `"category": "person|org|contact|id|financial|other"` — 6 категорий вместо 10+ типов.
|
||||
|
||||
---
|
||||
|
||||
## Q4. Двухпроходная или однопроходная?
|
||||
|
||||
**Двухпроходная — обязательно.** Причина: «Иванов» в 5 документах должен заменяться одинаково. Однопроход не может этого гарантировать.
|
||||
|
||||
Текущий `TwoPassObfuscator` — правильный, не трогать.
|
||||
|
||||
---
|
||||
|
||||
## Q5. Новый промпт
|
||||
|
||||
```
|
||||
You are a PII detector. Find ALL sensitive or private information.
|
||||
|
||||
Return JSON array: [{"type": "short_label", "value": "exact_string"}]
|
||||
|
||||
Rules:
|
||||
- Copy "value" VERBATIM from text
|
||||
- "type" is snake_case label you invent
|
||||
- Same value → include once
|
||||
- Return ONLY JSON
|
||||
```
|
||||
|
||||
Ключевое: нет ограничения на типы, value verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Q6. DOCX или Markdown?
|
||||
|
||||
- **Внутренняя обработка:** Markdown проще парсить, LLM понимает лучше.
|
||||
- **На выходе:** опция `output_format: "docx" | "md"`. По умолчанию `"docx"`.
|
||||
|
||||
---
|
||||
|
||||
## Итоговая карта изменений
|
||||
|
||||
```
|
||||
scanner.py — новый промпт (убрать список типов, LLM сама называет типы)
|
||||
generators/ — добавить fallback-генератор для неизвестных типов
|
||||
obfuscator.py — вызывать fallback если тип неизвестен
|
||||
api_bp.py — принять параметр output_format
|
||||
replacer.py — добавить replace_to_markdown()
|
||||
builder.py — упаковывать .md если output_format=md
|
||||
```
|
||||
|
||||
**Не трогать:** двухпроходная схема, checksum-генераторы, ZIP-безопасность.
|
||||
@@ -0,0 +1,51 @@
|
||||
# План v3 — универсальная архитектура (2026-07-12)
|
||||
|
||||
## Цель
|
||||
|
||||
Убрать жёсткую привязку к фиксированному списку типов сущностей.
|
||||
Перейти на Markdown как внутренний формат.
|
||||
|
||||
## Изменения по модулям
|
||||
|
||||
### 1. `scanner.py` — универсальный LLM-промпт
|
||||
- Убрать список типов из промпта
|
||||
- LLM сама называет типы (snake_case)
|
||||
- `value` — verbatim из текста
|
||||
|
||||
### 2. `generators/` — fallback-генератор
|
||||
- Новый `fallback.py`: character-class preserving замена
|
||||
- `__init__.py`: добавить в ENTITY_GENERATORS
|
||||
- `obfuscator.py`: использовать fallback для неизвестных типов
|
||||
|
||||
### 3. `extractor.py` — единый MD-пайплайн
|
||||
- DOCX → MD (python-docx: стили, форматирование)
|
||||
- PDF → MD (pdfplumber: текст + таблицы)
|
||||
- TXT → как есть
|
||||
- Убрать convert_pdfs_to_docx (не нужен)
|
||||
- Убрать хранение docx-объектов
|
||||
|
||||
### 4. `replacer.py` — упростить
|
||||
- `apply_replacements(text)` — уже есть
|
||||
- `replace_in_docx(doc, mapping)` — сохранить как утилиту для DOCX-выхода
|
||||
- Удалить `replace_in_text()` (заменяется на apply_replacements)
|
||||
|
||||
### 5. `obfuscator.py` — обновить пайплайн
|
||||
- Pass 1: extract MD → scan_regex + scan_llm_ner
|
||||
- Pass 2: apply_replacements к MD
|
||||
- Опционально: replace_in_docx к оригинальному DOCX
|
||||
|
||||
### 6. `api_bp.py` — output_format
|
||||
- Параметр `output_format: "md" | "docx"` (по умолчанию md)
|
||||
|
||||
### 7. `.doc` — потом
|
||||
- Добавим convert_doc_to_docx через LibreOffice отдельно
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. `generators/fallback.py` + обновить `__init__.py`
|
||||
2. `scanner.py` — новый промпт
|
||||
3. `extractor.py` — MD-конвертация
|
||||
4. `replacer.py` — упростить
|
||||
5. `obfuscator.py` — новый пайплайн
|
||||
6. `api_bp.py` — output_format
|
||||
7. Проверить всё → commit
|
||||
@@ -0,0 +1,142 @@
|
||||
# DrHider — обфускация документов (Managed Flask)
|
||||
|
||||
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
|
||||
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
|
||||
**Платформа:** Штурвал (Managed Flask на pythonk8s)
|
||||
**Дизайн фронтенда:** `/home/naeel/nubes/design/`
|
||||
|
||||
---
|
||||
|
||||
## Что делает
|
||||
|
||||
Загружаешь документы (.docx, .pdf, .txt, .zip) — получаешь ZIP с обфусцированными копиями.
|
||||
Все персональные данные, реквизиты, телефоны, email заменяются на фиктивные.
|
||||
В архиве — mapping.csv с таблицей замен (оригинал → фиктивное).
|
||||
|
||||
Обфускация двухпроходная:
|
||||
1. **Проход 1 (сбор):** regex + LLM находят все сущности во всех файлах → глобальный словарь замен
|
||||
2. **Проход 2 (замена):** применяем замены ко всем файлам → ZIP
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ ПРАВИЛА ДЛЯ АГЕНТОВ (читать перед ЛЮБЫМ действием)
|
||||
|
||||
### 1. НЕ смотреть локальные папки contracts/contracts-flask
|
||||
Весь старый код drhider — ТОЛЬКО на ВМ (`5.172.178.213`). НЕ локально.
|
||||
Единственное исключение: `/home/naeel/nubes/loadtest/` (образец структуры managed Flask).
|
||||
|
||||
### 2. Штурвал запускает Flask САМ
|
||||
- БЕЗ Dockerfile (удалён)
|
||||
- БЕЗ gunicorn (нет в requirements.txt)
|
||||
- Точка входа: `site/app.py` → `app = create_app()`
|
||||
- Деплой: `git push origin master` → авто-деплой в UI Штурвала
|
||||
|
||||
### 3. НЕ менять код без «делай»
|
||||
Даже если ошибка очевидна. Показать → описать → ЖДАТЬ.
|
||||
|
||||
### 4. После ЛЮБОЙ правки:
|
||||
```bash
|
||||
python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"
|
||||
git add -A && git commit -m "подробное описание" && git push
|
||||
```
|
||||
|
||||
### 5. Документировать ВСЁ в History/
|
||||
Каждое изменение, план, ошибку — в отдельный .md файл.
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
### `drhider/` — пакет ядра обфускации
|
||||
|
||||
| Файл | Назначение | Строк |
|
||||
|---|---|---|
|
||||
| `__init__.py` | re-export: `obfuscate_files`, `LLMClient`, `TwoPassObfuscator` | 10 |
|
||||
| `config.py` | Константы: regex-паттерны (ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN), словари имён/городов/улиц | 80 |
|
||||
| `checksum.py` | Контрольные суммы: ИНН10, ИНН12, ОГРН | 60 |
|
||||
| `random_utils.py` | Утилиты: `random_digits`, `random_letters` | 20 |
|
||||
| `llm_client.py` | LLMClient — HTTP-клиент к LLM API (aillm.ru) | 50 |
|
||||
| `extractor.py` | Извлечение текста из .docx/.pdf/.txt + `expand_zips` + `convert_pdfs_to_docx` | 180 |
|
||||
| `scanner.py` | Проход 1: `scan_regex` (regex) + `scan_llm_ner` (LLM NER) | 110 |
|
||||
| `replacer.py` | Проход 2: `apply_replacements`, `replace_in_docx`, `replace_in_text` | 100 |
|
||||
| `builder.py` | Сборка: `build_zip`, `build_mapping_csv` | 65 |
|
||||
| `obfuscator.py` | `TwoPassObfuscator` — оркестратор + `obfuscate_files()` | 100 |
|
||||
|
||||
### `drhider/generators/` — генераторы фиктивных значений
|
||||
|
||||
Каждый файл — один генератор (10–40 строк). Интерфейс: `generate_xxx(original: str) -> str`.
|
||||
|
||||
| Файл | Что генерирует |
|
||||
|---|---|
|
||||
| `phone.py` | +7 (XXX) XXX-XX-XX |
|
||||
| `email.py` | user@fake-domain |
|
||||
| `inn.py` | ИНН 10 и 12 знаков (с контрольной суммой) |
|
||||
| `ogrn.py` | ОГРН 13 знаков |
|
||||
| `kpp.py` | КПП 9 знаков |
|
||||
| `bik.py` | БИК 9 знаков (04XXXXXXX) |
|
||||
| `accounts.py` | Расчётный счёт (40702...) + корр. счёт (30101...) |
|
||||
| `passport.py` | Паспорт (XX XX XXXXXX) |
|
||||
| `company.py` | ООО/ЗАО/АО «СлучайноеНазвание» |
|
||||
| `person.py` | Фамилия И.О. |
|
||||
| `address.py` | Город, ул. Улица, д. N |
|
||||
|
||||
`__init__.py` содержит `ENTITY_GENERATORS` — маппинг `entity_type → генератор`.
|
||||
|
||||
### `site/` — Flask-приложение
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `app.py` | `create_app()` — создание Flask, регистрация blueprint'ов, VERSION |
|
||||
| `routes/__init__.py` | `register_routes(app)` |
|
||||
| `routes/main_bp.py` | `GET /` — HTML-интерфейс |
|
||||
| `routes/health_bp.py` | `GET /health` — `{"ok": true, "version": "..."}` |
|
||||
| `routes/api_bp.py` | `POST /api/drhider` — multipart files → ZIP |
|
||||
| `templates/index.html` | UI (выбор файлов, прогресс, скачивание) |
|
||||
| `static/favicon.svg` | Иконка |
|
||||
|
||||
### Корневые файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `requirements.txt` | `flask`, `python-docx`, `pdfplumber`, `httpx` |
|
||||
| `.gitignore` | `__pycache__/`, `*.pyc`, `.env` |
|
||||
| `.gitea/workflows/deploy.yaml` | CI: валидация Python |
|
||||
| `History/` | Документация всех изменений |
|
||||
| `README.md` | Этот файл |
|
||||
| `docs/ARCHITECTURE.md` | Архитектура |
|
||||
| `docs/DEPLOY.md` | Деплой, ВМ, переменные окружения |
|
||||
| `docs/DEVELOPMENT.md` | Порядок действий при изменении кода |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Метод | Путь | Что делает |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | HTML-интерфейс |
|
||||
| `GET` | `/health` | `{"ok": true, "version": "2.0.0"}` |
|
||||
| `POST` | `/api/drhider` | multipart/form-data (поле `files`) → `application/zip` |
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения (Штурвал)
|
||||
|
||||
| Переменная | Значение |
|
||||
|---|---|
|
||||
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
|
||||
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
|
||||
| `LLM_MODEL` | `gpt-oss-120b` |
|
||||
|
||||
---
|
||||
|
||||
## Связь с ВМ contracts (legacy)
|
||||
|
||||
На ВМ `5.172.178.213` (contracts.kube5s.ru) есть старая версия drhider:
|
||||
- `drhider_server.py` на порту 8767 — standalone HTTP-сервер
|
||||
- `drhider.py` — та же логика, что и в этом проекте (скопирована оттуда)
|
||||
|
||||
Этот проект (`drhider.pythonk8s.dev.nubes.ru`) — **независимый managed Flask**, заменяет ВМ-версию.
|
||||
|
||||
SSH к ВМ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
|
||||
@@ -0,0 +1,191 @@
|
||||
# Архитектура DrHider
|
||||
|
||||
## Общая схема
|
||||
|
||||
```
|
||||
Пользователь (браузер)
|
||||
│
|
||||
▼
|
||||
https://drhider.pythonk8s.dev.nubes.ru/
|
||||
│
|
||||
▼
|
||||
Штурвал (Managed Flask на pythonk8s)
|
||||
│
|
||||
▼
|
||||
site/app.py ──→ Flask (create_app)
|
||||
│
|
||||
├── GET / → main_bp → templates/index.html
|
||||
├── GET /health → health_bp → {"ok": true}
|
||||
└── POST /api/drhider → api_bp → drhider.obfuscate_files()
|
||||
│
|
||||
▼
|
||||
TwoPassObfuscator
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
extractor scanner replacer
|
||||
(текст) (regex+LLM) (замена)
|
||||
│ │ │
|
||||
└────────────┼────────────┘
|
||||
│
|
||||
builder
|
||||
(ZIP+CSV)
|
||||
│
|
||||
▼
|
||||
application/zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Двухпроходная обфускация
|
||||
|
||||
### Проход 1: сбор сущностей
|
||||
|
||||
```
|
||||
Файлы (.docx, .pdf, .txt, .zip)
|
||||
│
|
||||
▼
|
||||
extractor.expand_zips() ← распаковать ZIP
|
||||
│
|
||||
▼
|
||||
extractor.convert_pdfs_to_docx() ← PDF → DOCX
|
||||
│
|
||||
▼
|
||||
extractor.extract_text() ← извлечь текст из каждого файла
|
||||
│
|
||||
├──→ scanner.scan_regex() ← regex: телефоны, email, ИНН, ОГРН, КПП,
|
||||
│ БИК, счета, паспорта, компании, ФИО
|
||||
│
|
||||
└──→ scanner.scan_llm_ner() ← LLM: имена, адреса, паспорта
|
||||
(только если llm_client передан)
|
||||
│
|
||||
▼
|
||||
mapping = {оригинал → фиктивное} ← глобальный словарь замен
|
||||
```
|
||||
|
||||
### Проход 2: замена
|
||||
|
||||
```
|
||||
mapping + sorted_keys (по убыванию длины)
|
||||
│
|
||||
▼
|
||||
Для каждого файла:
|
||||
│
|
||||
├── .docx → replacer.replace_in_docx() ← склеить runs → заменить → сохранить
|
||||
├── .doc → без изменений (бинарный)
|
||||
└── .txt/.pdf → replacer.replace_in_text() ← простая строковая замена
|
||||
│
|
||||
▼
|
||||
builder.build_mapping_csv() ← mapping.csv
|
||||
builder.build_zip() ← ZIP со всеми файлами + mapping.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Модули
|
||||
|
||||
### `drhider/config.py`
|
||||
Константы уровня всего пакета. НЕ содержит логики — только данные.
|
||||
- `ENTITY_PATTERNS` — 10 regex-паттернов
|
||||
- `COMPANY_PATTERN`, `PERSON_PATTERN` — скомпилированные regex
|
||||
- Словари: `RU_SURNAMES`, `RU_NAMES`, `RU_PATRONYMICS`, `RU_CITIES`, `RU_STREETS`, `FAKE_DOMAINS`
|
||||
|
||||
### `drhider/checksum.py`
|
||||
Чистые функции для расчёта контрольных сумм. Без побочных эффектов.
|
||||
- `checksum_inn10(inn)` → 1 цифра
|
||||
- `checksum_inn12(inn)` → 2 цифры
|
||||
- `checksum_ogrn(ogrn)` → 1 цифра
|
||||
|
||||
### `drhider/random_utils.py`
|
||||
Утилиты генерации случайных строк.
|
||||
- `random_digits(n)` → строка из n цифр
|
||||
- `random_letters(n)` → строка из n строчных латинских букв
|
||||
|
||||
### `drhider/generators/`
|
||||
Каждый генератор — независимая функция с сигнатурой `generate_xxx(original: str) -> str`.
|
||||
Параметр `original` нужен для извлечения префикса (например, «ИНН » из «ИНН 1234567890»).
|
||||
Генераторы НЕ общаются друг с другом — только импортируют из `config`, `checksum`, `random_utils`.
|
||||
|
||||
`__init__.py` содержит `ENTITY_GENERATORS` — словарь, связывающий `entity_type` из `ENTITY_PATTERNS` с функцией-генератором. Используется в `scanner.scan_regex()`.
|
||||
|
||||
### `drhider/llm_client.py`
|
||||
HTTP-клиент к OpenAI-совместимому API. Читает переменные окружения.
|
||||
Интерфейс: `.complete(prompt: str) -> str`.
|
||||
|
||||
### `drhider/extractor.py`
|
||||
Три независимые функции (не класс):
|
||||
- `extract_text(fname, content, ctype)` → `(text, docx_document_or_None)`
|
||||
- `expand_zips(files)` → распакованный список
|
||||
- `convert_pdfs_to_docx(files)` → PDF заменены на DOCX
|
||||
|
||||
### `drhider/scanner.py`
|
||||
Две функции (не класс), мутируют переданный `mapping: Dict[str, str]`:
|
||||
- `scan_regex(text, mapping)` — regex-поиск
|
||||
- `scan_llm_ner(all_texts, mapping, llm_client)` — LLM NER
|
||||
|
||||
### `drhider/replacer.py`
|
||||
Три чистые функции:
|
||||
- `apply_replacements(text, mapping, sorted_keys)` → строка с заменами
|
||||
- `replace_in_docx(doc, mapping, sorted_keys)` → bytes (изменённый DOCX)
|
||||
- `replace_in_text(text, fname, mapping, sorted_keys)` → bytes
|
||||
|
||||
### `drhider/builder.py`
|
||||
Две функции сборки результата:
|
||||
- `build_zip(files, mapping_csv)` → bytes (ZIP-архив)
|
||||
- `build_mapping_csv(mapping)` → str (CSV)
|
||||
|
||||
### `drhider/obfuscator.py`
|
||||
`TwoPassObfuscator` — оркестратор. Единственный класс, который знает о顺序е вызовов.
|
||||
Метод `.obfuscate(files)` вызывает модули в правильном порядке.
|
||||
`obfuscate_files()` — удобная функция-обёртка.
|
||||
|
||||
### `site/app.py`
|
||||
Точка входа для Штурвала. `create_app()` создаёт Flask, регистрирует blueprint'ы.
|
||||
`app = create_app()` — глобальный экземпляр.
|
||||
|
||||
### `site/routes/`
|
||||
Blueprint'ы — каждый в своём файле:
|
||||
- `main_bp` — только `GET /`
|
||||
- `health_bp` — только `GET /health`
|
||||
- `api_bp` — только `POST /api/drhider`
|
||||
|
||||
`__init__.py` содержит `register_routes(app)` — единая точка регистрации.
|
||||
|
||||
---
|
||||
|
||||
## Поток данных
|
||||
|
||||
```
|
||||
HTTP POST /api/drhider (multipart/form-data)
|
||||
│
|
||||
▼
|
||||
api_bp.drhider()
|
||||
│ request.files.getlist("files")
|
||||
│ → [(filename, bytes, mimetype), ...]
|
||||
▼
|
||||
obfuscate_files(files, llm_client=LLMClient())
|
||||
│
|
||||
▼
|
||||
TwoPassObfuscator.obfuscate(files)
|
||||
│
|
||||
├── expand_zips → convert_pdfs_to_docx → extract_text
|
||||
├── scan_regex + scan_llm_ner → mapping
|
||||
├── replace_in_docx / replace_in_text → обфусцированные файлы
|
||||
└── build_mapping_csv + build_zip → (zip_bytes, csv_str)
|
||||
│
|
||||
▼
|
||||
send_file(BytesIO(zip_data), mimetype="application/zip")
|
||||
│
|
||||
▼
|
||||
HTTP 200 + ZIP-архив
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Принципы
|
||||
|
||||
1. **Модули — чистые функции.** Где возможно — без классов, без состояния.
|
||||
2. **Единственный класс — TwoPassObfuscator.** Только он управляет состоянием (mapping, sorted_keys).
|
||||
3. **Генераторы — изолированы.** Каждый в своём файле, не зависят друг от друга.
|
||||
4. **Flask — тонкая прослойка.** Только принимает запрос, вызывает `obfuscate_files()`, отдаёт ответ.
|
||||
5. **Без БД.** Всё в памяти. Никаких внешних зависимостей кроме LLM API.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Деплой, ВМ и переменные окружения
|
||||
|
||||
---
|
||||
|
||||
## Платформа
|
||||
|
||||
**Штурвал** — Managed Flask на pythonk8s (Kubernetes).
|
||||
|
||||
- Запускает Flask **сам** — без Dockerfile, без gunicorn
|
||||
- Точка входа: `site/app.py` → глобальная переменная `app`
|
||||
- Деплой: `git push origin master` → авто-деплой через UI Штурвала
|
||||
|
||||
---
|
||||
|
||||
## Деплой
|
||||
|
||||
```bash
|
||||
cd /home/naeel/nubes/drhider
|
||||
git add -A
|
||||
git commit -m "описание изменений"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
После пуша — зайти в UI Штурвала и нажать ** Redeploy** для `drhider`.
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения (настраиваются в UI Штурвала)
|
||||
|
||||
| Переменная | Значение | Для чего |
|
||||
|---|---|---|
|
||||
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | Ключ доступа к LLM API |
|
||||
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
|
||||
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
|
||||
|
||||
Без `LLM_API_KEY` LLM-сканирование не работает (только regex).
|
||||
|
||||
---
|
||||
|
||||
## ВМ contracts (legacy)
|
||||
|
||||
Старый сервер contracts.kube5s.ru. DrHider там — standalone HTTP-сервер на порту 8767.
|
||||
|
||||
### SSH
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
|
||||
```
|
||||
|
||||
### Где лежит старый drhider
|
||||
|
||||
```
|
||||
/home/naeel/contracts/drhider/
|
||||
├── drhider.py # Ядро обфускации (отсюда скопировано в этот проект)
|
||||
├── drhider_server.py # HTTP-сервер :8767 (НЕ ИСПОЛЬЗУЕТСЯ в новом проекте)
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
### Сервисы на ВМ
|
||||
|
||||
| Сервис | Порт | systemd unit |
|
||||
|---|---|---|
|
||||
| drhider | 8767 | `contracts-drhider` |
|
||||
| convert_server | 8766 | `contracts` |
|
||||
| Flask UI | 5001 | — (start.sh) |
|
||||
| nginx | 443 | `nginx` |
|
||||
| PostgreSQL | 5432 | — |
|
||||
|
||||
### Управление drhider на ВМ
|
||||
|
||||
```bash
|
||||
# Статус
|
||||
systemctl status contracts-drhider
|
||||
|
||||
# Логи
|
||||
journalctl -u contracts-drhider -f
|
||||
|
||||
# Перезапуск
|
||||
sudo systemctl restart contracts-drhider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
```bash
|
||||
cd /home/naeel/nubes/drhider
|
||||
|
||||
# Установка зависимостей
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Запуск Flask (dev-сервер)
|
||||
cd site && python3 app.py
|
||||
|
||||
# Проверка синтаксиса ВСЕХ файлов
|
||||
python3 -c "
|
||||
import py_compile, os
|
||||
for root, dirs, files in os.walk('.'):
|
||||
for f in files:
|
||||
if f.endswith('.py'):
|
||||
path = os.path.join(root, f)
|
||||
py_compile.compile(path, doraise=True)
|
||||
print(f'OK: {path}')
|
||||
"
|
||||
|
||||
# Проверка импорта
|
||||
python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Как править код
|
||||
|
||||
1. Понять что меняем → **описать план**
|
||||
2. Получить **«делай»**
|
||||
3. Внести изменения
|
||||
4. `python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"` — КАЖДЫЙ изменённый .py
|
||||
5. `python3 -c "from drhider import obfuscate_files, LLMClient"` — проверка импорта
|
||||
6. `git add -A && git commit -m "подробно что и зачем" && git push`
|
||||
7. Записать в `History/` что сделано
|
||||
8. Зайти в UI Штурвала → Redeploy
|
||||
|
||||
---
|
||||
|
||||
## Как добавить новый генератор
|
||||
|
||||
1. Создать `drhider/generators/новый_тип.py` с функцией `generate_xxx(original: str) -> str`
|
||||
2. Добавить regex-паттерн в `drhider/config.py` → `ENTITY_PATTERNS`
|
||||
3. Добавить импорт и маппинг в `drhider/generators/__init__.py` → `ENTITY_GENERATORS`
|
||||
4. Проверить синтаксис → commit → push → Redeploy
|
||||
|
||||
---
|
||||
|
||||
## История версий
|
||||
|
||||
| Версия | Дата | Что |
|
||||
|---|---|---|
|
||||
| 2.0.0 | 2026-07-12 | Модульный рефакторинг (22 модуля вместо монолита) |
|
||||
| 1.0.0 | 2026-07-11 | Начальная версия (monolith drhider.py + Flask) |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Порядок действий при изменении кода
|
||||
|
||||
**Читать перед ЛЮБОЙ правкой.**
|
||||
|
||||
---
|
||||
|
||||
## ⛔ ЗАПРЕЩЕНО
|
||||
|
||||
1. Менять код без «делай»
|
||||
2. Действовать по догадкам («я бы сделал так», «можно попробовать»)
|
||||
3. Удалять файлы/папки без разрешения
|
||||
4. «Улучшать» рабочий код без прямого разрешения
|
||||
5. Использовать `sed`
|
||||
6. Откладывать commit/push «на потом»
|
||||
7. Игнорировать проверку синтаксиса перед push
|
||||
|
||||
---
|
||||
|
||||
## ✅ ПОРЯДОК ПРИ ЛЮБОМ ИЗМЕНЕНИИ
|
||||
|
||||
### Шаг 1 — План
|
||||
```
|
||||
Описать что меняем → получить «делай»
|
||||
```
|
||||
|
||||
### Шаг 2 — Правка
|
||||
Вносим изменения. Только то, что обговорено. Никакой самодеятельности.
|
||||
|
||||
### Шаг 3 — Проверка синтаксиса
|
||||
```bash
|
||||
python3 -c "import py_compile; py_compile.compile('путь/к/файлу.py', doraise=True)"
|
||||
```
|
||||
**КАЖДЫЙ изменённый .py файл.**
|
||||
|
||||
### Шаг 4 — Проверка импорта
|
||||
```bash
|
||||
cd /home/naeel/nubes/drhider && python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
|
||||
```
|
||||
|
||||
### Шаг 5 — Проверить соседей
|
||||
После `replace_string_in_file` — прочитать соседние строки, убедиться что не затёр соседнюю функцию/класс.
|
||||
|
||||
### Шаг 6 — commit + push (СРАЗУ)
|
||||
```bash
|
||||
cd /home/naeel/nubes/drhider
|
||||
git add -A
|
||||
git commit -m "подробное описание что и зачем"
|
||||
git push
|
||||
```
|
||||
**Без исключений. Никаких накоплений.**
|
||||
|
||||
### Шаг 7 — Документировать
|
||||
Записать в `History/` что сделано:
|
||||
- Отдельный `.md` файл
|
||||
- Что планировалось → что сделано → в чём ошибся
|
||||
|
||||
### Шаг 8 — Redeploy
|
||||
Зайти в UI Штурвала → Redeploy `drhider`.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Команды в терминале — всегда с таймаутами
|
||||
|
||||
```bash
|
||||
curl --max-time 30 ...
|
||||
ssh -o ConnectTimeout=10 ...
|
||||
timeout 10 grep ... file.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Проверка ВСЕХ файлов разом
|
||||
|
||||
```bash
|
||||
cd /home/naeel/nubes/drhider
|
||||
python3 -c "
|
||||
import py_compile, os
|
||||
for root, dirs, files in os.walk('.'):
|
||||
for f in files:
|
||||
if f.endswith('.py'):
|
||||
py_compile.compile(os.path.join(root, f), doraise=True)
|
||||
print(f'OK: {os.path.join(root, f)}')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Версионирование
|
||||
|
||||
Версия в `site/app.py` (переменная `VERSION`).
|
||||
Менять при любом изменении кода, влияющем на поведение.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Пример хорошего коммита
|
||||
|
||||
```
|
||||
git commit -m "generators: добавил generate_snils — генератор фиктивных СНИЛС
|
||||
|
||||
- Новый файл drhider/generators/snils.py
|
||||
- Добавлен паттерн в config.py → ENTITY_PATTERNS
|
||||
- Добавлен в generators/__init__.py → ENTITY_GENERATORS
|
||||
- bump VERSION 2.0.0 → 2.0.1"
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
НЕ СПЕШИ ! не ошибайся. НЕ ПРЕДПОЛАГАЙ !
|
||||
не надо ДОГАДОК !
|
||||
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
|
||||
есть сомнения - лучше остановись и спроси
|
||||
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
|
||||
+12
-29
@@ -8,10 +8,8 @@
|
||||
|
||||
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
|
||||
@@ -20,50 +18,35 @@ 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.
|
||||
В архив добавляются:
|
||||
- Все обфусцированные файлы (с оригинальными именами)
|
||||
- mapping.csv — таблица соответствия оригинал→замена (если не пустая)
|
||||
|
||||
Имена файлов в архиве — UTF-8 (бит 11 в flag_bits).
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes), ...]
|
||||
mapping_csv: Строка CSV с таблицей замен (опционально, НЕ добавляется в архив)
|
||||
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 = zipfile.ZipInfo(fname)
|
||||
info.flag_bits |= 0x800 # Флаг: имя файла в UTF-8
|
||||
zf.writestr(info, content)
|
||||
|
||||
# Добавляем mapping.csv с BOM (для корректного открытия в Excel)
|
||||
if mapping_csv:
|
||||
zf.writestr(
|
||||
"mapping.csv",
|
||||
'\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'),
|
||||
)
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Контрольные суммы для ИНН (10 и 12 знаков) и ОГРН.
|
||||
|
||||
Используются генераторами фиктивных значений для создания
|
||||
синтаксически корректных номеров.
|
||||
"""
|
||||
|
||||
|
||||
def checksum_inn10(inn: str) -> str:
|
||||
"""Контрольная сумма для 10-значного ИНН (юридические лица).
|
||||
|
||||
Алгоритм:
|
||||
1. Умножаем первые 9 цифр на коэффициенты [2,4,10,3,5,9,4,6,8]
|
||||
2. Суммируем, берём (сумма % 11) % 10
|
||||
|
||||
Args:
|
||||
inn: 9 цифр ИНН (без контрольной суммы)
|
||||
|
||||
Returns:
|
||||
Одна цифра контрольной суммы
|
||||
"""
|
||||
coeffs = [2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
s = sum(int(inn[i]) * coeffs[i] for i in range(9))
|
||||
return str((s % 11) % 10)
|
||||
|
||||
|
||||
def checksum_inn12(inn: str) -> str:
|
||||
"""Контрольные суммы для 12-значного ИНН (физические лица).
|
||||
|
||||
Алгоритм (две контрольные цифры):
|
||||
1. n1: умножаем первые 10 цифр на [7,2,4,10,3,5,9,4,6,8],
|
||||
берём (сумма % 11) % 10
|
||||
2. n2: добавляем n1 к числу, умножаем 11 цифр на
|
||||
[3,7,2,4,10,3,5,9,4,6,8], берём (сумма % 11) % 10
|
||||
|
||||
Args:
|
||||
inn: 10 цифр ИНН (без контрольных сумм)
|
||||
|
||||
Returns:
|
||||
Две цифры контрольных сумм (n1 + n2)
|
||||
"""
|
||||
c1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
c2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
|
||||
# Первая контрольная цифра
|
||||
s1 = sum(int(inn[i]) * c1[i] for i in range(10))
|
||||
n1 = (s1 % 11) % 10
|
||||
|
||||
# Вторая контрольная цифра (с учётом первой)
|
||||
inn2 = inn + str(n1)
|
||||
s2 = sum(int(inn2[i]) * c2[i] for i in range(11))
|
||||
n2 = (s2 % 11) % 10
|
||||
|
||||
return str(n1) + str(n2)
|
||||
|
||||
|
||||
def checksum_ogrn(ogrn: str) -> str:
|
||||
"""Контрольная сумма для ОГРН (13 знаков).
|
||||
|
||||
Алгоритм: первые 12 цифр как число % 11, затем % 10.
|
||||
|
||||
Args:
|
||||
ogrn: 12 цифр ОГРН (без контрольной суммы)
|
||||
|
||||
Returns:
|
||||
Одна цифра контрольной суммы
|
||||
"""
|
||||
s = int(ogrn) % 11
|
||||
return str(s % 10)
|
||||
+2
-30
@@ -41,8 +41,8 @@ COMPANY_PATTERN = re.compile(
|
||||
|
||||
# ФИО: Фамилия + инициалы (Иванов И.И.) или полное имя (Иванов Иван Иванович)
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
|
||||
r'\b[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+\s+[А-Я][а-я]+\s+[А-Я][а-я]+',
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -86,31 +86,3 @@ RU_STREETS = [
|
||||
|
||||
# Фиктивные домены для генерации email
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Пулы для гибридных токенов (шаблон + номер)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Существительные для названий компаний (20)
|
||||
COMPANY_NOUNS = [
|
||||
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан",
|
||||
"Спектр", "Формат", "Модуль", "Ресурс", "Платформа",
|
||||
"Система", "Комплекс", "Проект", "Решение", "Сервис",
|
||||
]
|
||||
|
||||
# Города для адресов (20)
|
||||
CITY_POOL = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний_Новгород", "Челябинск", "Самара", "Омск",
|
||||
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь",
|
||||
"Волгоград", "Краснодар", "Саратов", "Тюмень", "Иркутск", "Хабаровск",
|
||||
]
|
||||
|
||||
# Улицы для адресов (20)
|
||||
STREET_POOL = [
|
||||
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
|
||||
"Школьная", "Лесная", "Полевая", "Строителей", "Набережная",
|
||||
"Парковая", "Солнечная", "Зелёная", "Юбилейная", "Весенняя",
|
||||
]
|
||||
|
||||
+177
-274
@@ -1,243 +1,95 @@
|
||||
"""
|
||||
Извлечение текста из документов — все форматы → Markdown.
|
||||
Извлечение текста из документов разных форматов.
|
||||
|
||||
Поддерживает:
|
||||
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
|
||||
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
|
||||
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
|
||||
- .txt и прочие → как есть
|
||||
- .docx (через python-docx)
|
||||
- .pdf (через pdfplumber)
|
||||
- .doc (бинарный — не парсится)
|
||||
- .txt и прочие (как UTF-8)
|
||||
|
||||
Также содержит:
|
||||
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
|
||||
- _expand_zips — распаковка ZIP с защитой от ZIP-бомб
|
||||
- _convert_pdfs_to_docx — конвертация PDF → DOCX
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
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.
|
||||
def extract_text(fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
|
||||
"""Извлечь текст из одного файла.
|
||||
|
||||
Args:
|
||||
fname: Имя файла (с расширением)
|
||||
content: Бинарное содержимое файла
|
||||
ctype: MIME-тип (зарезервировано, не используется)
|
||||
ctype: MIME-тип (не используется в текущей версии)
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown
|
||||
(text, docx_document_or_None):
|
||||
text — извлечённый текст (строка)
|
||||
doc — объект python-docx Document или None
|
||||
"""
|
||||
doc = None
|
||||
text = ""
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
# ── .docx: полный Markdown с форматированием ──
|
||||
# ── .docx: извлекаем текст + сохраняем Document для замен с форматированием ──
|
||||
if ext == '.docx':
|
||||
try:
|
||||
return docx_to_markdown(content)
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
# Если python-docx не установлен — читаем как plain text
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
|
||||
# ── .pdf: текст + таблицы ──
|
||||
doc = Document(io.BytesIO(content))
|
||||
# Собираем текст из параграфов
|
||||
paragraphs = [p.text for p in doc.paragraphs]
|
||||
# Добавляем текст из таблиц
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(cell.text for cell in row.cells)
|
||||
paragraphs.append(row_text)
|
||||
text = "\n".join(paragraphs)
|
||||
|
||||
# ── .pdf: извлекаем текст через pdfplumber ──
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
return pdf_to_markdown(content)
|
||||
import pdfplumber
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
|
||||
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
|
||||
parts = []
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
# Текст страницы
|
||||
t = page.extract_text()
|
||||
if t:
|
||||
parts.append(t)
|
||||
# Текст из таблиц
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
row_str = " | ".join(str(c) if c else "" for c in row)
|
||||
parts.append(row_str)
|
||||
text = "\n".join(parts)
|
||||
|
||||
# ── .doc: бинарный формат — без libreoffice не парсим ──
|
||||
elif ext == '.doc':
|
||||
return doc_to_markdown(content)
|
||||
text = "[DOC binary — not parsed]"
|
||||
return text, None
|
||||
|
||||
# ── .txt и прочие ──
|
||||
# ── .txt и прочие: читаем как UTF-8 ──
|
||||
else:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
|
||||
return text, doc
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ZIP-распаковка (без изменений)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
|
||||
@@ -255,103 +107,154 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
|
||||
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)
|
||||
|
||||
for fname, content, ctype in files:
|
||||
# Пропускаем не-ZIP
|
||||
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:
|
||||
# Проверка: не более 500 файлов в архиве
|
||||
if len(zf.infolist()) > 500:
|
||||
log.warning("ZIP too many files, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
# Проверяем лимиты ДО чтения содержимого (защита от бомб)
|
||||
bomb = False
|
||||
for info in infos:
|
||||
total_uncompressed = 0
|
||||
|
||||
for info in zf.infolist():
|
||||
# Пропускаем директории
|
||||
if info.is_dir():
|
||||
continue
|
||||
|
||||
# Проверка на ZIP-бомбу: ratio
|
||||
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 ratio > 100: # Файл сжимается более чем в 100 раз
|
||||
log.warning(
|
||||
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
|
||||
)
|
||||
result.append((fname, content, ctype))
|
||||
break # Пропускаем весь архив
|
||||
|
||||
if bomb:
|
||||
log.warning("ZIP bomb/limit, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
# Декодируем имя файла: cp437 → utf-8
|
||||
name = info.filename
|
||||
try:
|
||||
name = name.encode("cp437").decode("utf-8", errors="replace")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
|
||||
for info in infos:
|
||||
name = _decode_name(info.filename, info)
|
||||
# Защита от path traversal
|
||||
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'))):
|
||||
if (
|
||||
not name
|
||||
or name.endswith("/")
|
||||
or ".." in name
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
):
|
||||
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)
|
||||
|
||||
if total_uncompressed > 500 * 1024 * 1024: # 500 MB
|
||||
log.warning(
|
||||
"ZIP uncompressed limit exceeded, stopping: %s", fname
|
||||
)
|
||||
break
|
||||
|
||||
# Вложенные ZIP добавляем в очередь на повторную распаковку
|
||||
queue.append((name, inner_data, ""))
|
||||
result.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
|
||||
|
||||
|
||||
def convert_pdfs_to_docx(
|
||||
files: List[Tuple[str, bytes, str]],
|
||||
) -> List[Tuple[str, bytes, str]]:
|
||||
"""Конвертировать PDF-файлы в DOCX через pdfplumber.
|
||||
|
||||
Не-PDF файлы проходят без изменений.
|
||||
При совпадении имён к имени добавляется суффикс '_из_pdf'.
|
||||
|
||||
Конвертация:
|
||||
- Текст страницы → параграфы DOCX
|
||||
- Таблицы → таблицы DOCX со стилем 'Table Grid'
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
Новый список файлов (PDF заменены на DOCX)
|
||||
"""
|
||||
import pdfplumber
|
||||
from docx import Document as DocxDocument
|
||||
|
||||
result: List[Tuple[str, bytes, str]] = []
|
||||
existing_names = {f[0] for f in files}
|
||||
|
||||
for fname, content, ctype in files:
|
||||
# Пропускаем не-PDF
|
||||
if not fname.lower().endswith('.pdf'):
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
try:
|
||||
doc = DocxDocument()
|
||||
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
# ── Таблицы ──
|
||||
tables = page.extract_tables()
|
||||
for table in tables:
|
||||
if not table:
|
||||
continue
|
||||
|
||||
# Чистим строки: убираем полностью пустые
|
||||
rows = [
|
||||
[str(c or "").strip() for c in (row or [])]
|
||||
for row in table
|
||||
]
|
||||
rows = [r for r in rows if any(r)]
|
||||
|
||||
if rows:
|
||||
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
|
||||
t.style = 'Table Grid'
|
||||
for ri, row in enumerate(rows):
|
||||
for ci, cell_text in enumerate(row):
|
||||
t.rows[ri].cells[ci].text = cell_text
|
||||
|
||||
# ── Текст ──
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
for line in text.split('\n'):
|
||||
line = line.strip()
|
||||
if line:
|
||||
doc.add_paragraph(line)
|
||||
|
||||
# Сохраняем DOCX в буфер
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
# Формируем новое имя
|
||||
new_name = fname[:-4] + '.docx'
|
||||
if new_name in existing_names:
|
||||
new_name = fname[:-4] + '_из_pdf.docx'
|
||||
existing_names.add(new_name)
|
||||
|
||||
result.append((new_name, buf.getvalue(), ctype))
|
||||
|
||||
except Exception as e:
|
||||
log.warning("PDF→DOCX error for %s: %s", fname, e)
|
||||
# При ошибке — оставляем оригинальный PDF
|
||||
result.append((fname, content, ctype))
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Маппинг типов сущностей на генераторы фиктивных значений.
|
||||
|
||||
ENTITY_GENERATORS используется в TwoPassObfuscator._scan_regex()
|
||||
для автоматического выбора генератора по типу найденной сущности.
|
||||
"""
|
||||
|
||||
from .phone import generate_phone
|
||||
from .email import generate_email
|
||||
from .inn import generate_inn10, generate_inn12
|
||||
from .ogrn import generate_ogrn
|
||||
from .kpp import generate_kpp
|
||||
from .bik import generate_bik
|
||||
from .accounts import generate_rs, generate_ks
|
||||
from .passport import generate_passport
|
||||
from .fallback import generate_fallback
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Маппинг: entity_type → функция-генератор
|
||||
# Ключи соответствуют ключам в config.ENTITY_PATTERNS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ENTITY_GENERATORS = {
|
||||
"phone": generate_phone,
|
||||
"email": generate_email,
|
||||
"inn_ul": generate_inn10, # 10-значный ИНН → юрлица
|
||||
"inn_fl": generate_inn12, # 12-значный ИНН → физлица
|
||||
"ogrn": generate_ogrn,
|
||||
"kpp": generate_kpp,
|
||||
"bik": generate_bik,
|
||||
"rs": generate_rs, # расчётный счёт
|
||||
"ks": generate_ks, # корреспондентский счёт
|
||||
"passport": generate_passport,
|
||||
}
|
||||
|
||||
# Fallback-генератор — экспортируется отдельно для использования в scanner/obfuscator
|
||||
FALLBACK_GENERATOR = generate_fallback
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Генераторы фиктивных банковских счетов.
|
||||
|
||||
- generate_rs: расчётный счёт (20 знаков)
|
||||
- generate_ks: корреспондентский счёт (20 знаков)
|
||||
|
||||
Счета начинаются с реальных префиксов: 40702 (расчётный), 30101 (корреспондентский).
|
||||
"""
|
||||
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
|
||||
|
||||
def generate_rs(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 20-значный расчётный счёт.
|
||||
|
||||
Сохраняет оригинальный префикс (р/с, расчётный счёт и т.д.).
|
||||
Начинается с 40702 (реальный префикс расчётного счёта).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с расчётным счётом
|
||||
|
||||
Returns:
|
||||
Строка вида «р/с 40702XXXXXXXXXXXXXXX»
|
||||
"""
|
||||
prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "40702" + random_digits(15)
|
||||
|
||||
|
||||
def generate_ks(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 20-значный корреспондентский счёт.
|
||||
|
||||
Сохраняет оригинальный префикс (к/с, кор/счёт и т.д.).
|
||||
Начинается с 30101 (реальный префикс корр. счёта).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с корр. счётом
|
||||
|
||||
Returns:
|
||||
Строка вида «к/с 30101XXXXXXXXXXXXXXX»
|
||||
"""
|
||||
prefix = re.match(
|
||||
r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*',
|
||||
original,
|
||||
re.IGNORECASE,
|
||||
).group(0)
|
||||
return prefix + "30101" + random_digits(15)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Генератор фиктивных почтовых адресов.
|
||||
|
||||
Формат: Город, ул. Улица, д. Номер
|
||||
"""
|
||||
|
||||
import random
|
||||
from ..config import RU_CITIES, RU_STREETS
|
||||
|
||||
|
||||
def generate_address(_: str) -> str:
|
||||
"""Сгенерировать фиктивный почтовый адрес.
|
||||
|
||||
Выбирает случайный город, улицу и номер дома из словарей.
|
||||
|
||||
Args:
|
||||
_: Оригинальный адрес (игнорируется)
|
||||
|
||||
Returns:
|
||||
Строка вида «Москва, ул. Ленина, д. 42»
|
||||
"""
|
||||
city = random.choice(RU_CITIES)
|
||||
street = random.choice(RU_STREETS)
|
||||
house = random.randint(1, 200)
|
||||
return f"{city}, ул. {street}, д. {house}"
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Генератор фиктивного БИК (9 знаков).
|
||||
|
||||
Формат: «БИК » + 9 цифр.
|
||||
Первые 2 цифры — 04 (код РФ).
|
||||
"""
|
||||
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
|
||||
|
||||
def generate_bik(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 9-значный БИК.
|
||||
|
||||
Сохраняет оригинальный префикс (например, «БИК »).
|
||||
Начинается с 04 (код Российской Федерации).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с БИК
|
||||
|
||||
Returns:
|
||||
Строка вида «БИК 04XXXXXXX»
|
||||
"""
|
||||
prefix = re.match(r'БИК\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "04" + random_digits(7)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Генератор фиктивных названий компаний.
|
||||
|
||||
Формат: ООО/ЗАО/АО «СлучайноеСуществительное»
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
|
||||
def generate_company(_: str) -> str:
|
||||
"""Сгенерировать фиктивное название компании.
|
||||
|
||||
Выбирает случайную организационно-правовую форму (ООО, ЗАО, АО)
|
||||
и случайное существительное из словаря.
|
||||
|
||||
Args:
|
||||
_: Оригинальное название (игнорируется)
|
||||
|
||||
Returns:
|
||||
Строка вида «ООО «Технология»»
|
||||
"""
|
||||
forms = ["ООО", "ЗАО", "АО"]
|
||||
nouns = [
|
||||
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат",
|
||||
]
|
||||
return f'{random.choice(forms)} «{random.choice(nouns)}»'
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Генератор фиктивного email-адреса.
|
||||
|
||||
Сохраняет структуру оригинального email: локальная_часть@домен.
|
||||
"""
|
||||
|
||||
import random
|
||||
from ..config import FAKE_DOMAINS
|
||||
from ..random_utils import random_letters
|
||||
|
||||
|
||||
def generate_email(original: str) -> str:
|
||||
"""Сгенерировать фиктивный email с тем же форматом.
|
||||
|
||||
Args:
|
||||
original: Оригинальный email (для сохранения структуры, не используется)
|
||||
|
||||
Returns:
|
||||
Фиктивный email вида случайные_буквы@фиктивный_домен
|
||||
"""
|
||||
domain = random.choice(FAKE_DOMAINS)
|
||||
local = random_letters(random.randint(5, 10))
|
||||
return f"{local}@{domain}"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Fallback-генератор фиктивных значений для неизвестных типов сущностей.
|
||||
|
||||
Используется когда LLM находит сущность с типом, которого нет
|
||||
в специализированных генераторах (например, "contract_number", "employee_id").
|
||||
|
||||
Принцип: character-class preserving замена.
|
||||
- Цифры → случайные цифры
|
||||
- Буквы → случайные буквы (того же алфавита)
|
||||
- Пробелы/знаки препинания → сохраняются
|
||||
- Длина строки сохраняется
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
def generate_fallback(original: str) -> str:
|
||||
"""Сгенерировать фиктивное значение, сохраняя структуру оригинала.
|
||||
|
||||
Правила замены:
|
||||
- [0-9] → случайная цифра
|
||||
- [A-Za-z] → случайная буква того же регистра
|
||||
- [А-Яа-я] → случайная кириллическая буква того же регистра
|
||||
- Все остальные символы (пробелы, дефисы, спецсимволы) → без изменений
|
||||
|
||||
Args:
|
||||
original: Оригинальное значение сущности
|
||||
|
||||
Returns:
|
||||
Фиктивное значение той же длины и структуры
|
||||
|
||||
Example:
|
||||
"Договор №123/2024" → "Фтщшлпь №847/5921"
|
||||
"EMP-0042" → "XQJ-8193"
|
||||
"""
|
||||
result = []
|
||||
|
||||
for ch in original:
|
||||
if ch.isdigit():
|
||||
# Цифра → случайная цифра
|
||||
result.append(random.choice(string.digits))
|
||||
|
||||
elif 'A' <= ch <= 'Z':
|
||||
# Заглавная латиница → случайная заглавная латиница
|
||||
result.append(random.choice(string.ascii_uppercase))
|
||||
|
||||
elif 'a' <= ch <= 'z':
|
||||
# Строчная латиница → случайная строчная латиница
|
||||
result.append(random.choice(string.ascii_lowercase))
|
||||
|
||||
elif 'А' <= ch <= 'Я':
|
||||
# Заглавная кириллица → случайная заглавная кириллица
|
||||
result.append(chr(random.randint(0x0410, 0x042F)))
|
||||
|
||||
elif 'а' <= ch <= 'я' or ch == 'ё':
|
||||
# Строчная кириллица → случайная строчная кириллица
|
||||
result.append(chr(random.randint(0x0430, 0x044F)))
|
||||
|
||||
else:
|
||||
# Всё остальное (пробелы, дефисы, слэши, точки) → сохранить
|
||||
result.append(ch)
|
||||
|
||||
return ''.join(result)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Генераторы фиктивных ИНН.
|
||||
|
||||
- generate_inn10: 10-значный ИНН (юридические лица)
|
||||
- generate_inn12: 12-значный ИНН (физические лица)
|
||||
|
||||
Оба сохраняют префикс «ИНН » и генерируют корректную контрольную сумму.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
from ..checksum import checksum_inn10, checksum_inn12
|
||||
|
||||
|
||||
def generate_inn10(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 10-значный ИНН (для юридических лиц).
|
||||
|
||||
Сохраняет оригинальный префикс (например, «ИНН »).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с ИНН
|
||||
|
||||
Returns:
|
||||
Строка вида «ИНН XXXXXXXXX-C» с корректной контрольной суммой
|
||||
"""
|
||||
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
|
||||
base = f"{random.randint(1,9)}{random_digits(8)}"
|
||||
return prefix + base + checksum_inn10(base)
|
||||
|
||||
|
||||
def generate_inn12(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 12-значный ИНН (для физических лиц).
|
||||
|
||||
Сохраняет оригинальный префикс (например, «ИНН »).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с ИНН
|
||||
|
||||
Returns:
|
||||
Строка вида «ИНН XXXXXXXXXX-CC» с двумя корректными контрольными суммами
|
||||
"""
|
||||
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
|
||||
base = f"{random.randint(1,9)}{random_digits(9)}"
|
||||
return prefix + base + checksum_inn12(base)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Генератор фиктивного КПП (9 знаков).
|
||||
|
||||
Формат: «КПП » + 9 цифр.
|
||||
Последние 3 цифры — код причины постановки (01, 43 или 77).
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
|
||||
|
||||
def generate_kpp(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 9-значный КПП.
|
||||
|
||||
Сохраняет оригинальный префикс (например, «КПП »).
|
||||
Последние 3 цифры — один из реальных кодов причины постановки.
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с КПП
|
||||
|
||||
Returns:
|
||||
Строка вида «КПП XXXX-код-XXX»
|
||||
"""
|
||||
prefix = re.match(r'КПП\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + random_digits(4) + random.choice(["01", "43", "77"]) + random_digits(3)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Генератор фиктивного ОГРН (13 знаков).
|
||||
|
||||
Формат: «ОГРН » + 13 цифр с корректной контрольной суммой.
|
||||
"""
|
||||
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
from ..checksum import checksum_ogrn
|
||||
|
||||
|
||||
def generate_ogrn(original: str) -> str:
|
||||
"""Сгенерировать фиктивный 13-значный ОГРН.
|
||||
|
||||
Сохраняет оригинальный префикс (например, «ОГРН »).
|
||||
Первая цифра всегда 1 (признак юридического лица).
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с ОГРН
|
||||
|
||||
Returns:
|
||||
Строка вида «ОГРН 1XXXXXXXXXXX-C» с корректной контрольной суммой
|
||||
"""
|
||||
prefix = re.match(r'ОГРН\s*', original, re.IGNORECASE).group(0)
|
||||
base = "1" + random_digits(11)
|
||||
return prefix + base + checksum_ogrn(base)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Генератор фиктивных паспортных данных.
|
||||
|
||||
Формат: «паспорт » / «серия номер » + XX XX XXXXXX
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from ..random_utils import random_digits
|
||||
|
||||
|
||||
def generate_passport(original: str) -> str:
|
||||
"""Сгенерировать фиктивные паспортные данные.
|
||||
|
||||
Сохраняет оригинальный префикс (паспорт, серия номер и т.д.).
|
||||
Формат: 2 цифры серии + 2 цифры + 6 цифр номера.
|
||||
|
||||
Args:
|
||||
original: Оригинальная строка с паспортными данными
|
||||
|
||||
Returns:
|
||||
Строка вида «паспорт XX XX XXXXXX»
|
||||
"""
|
||||
m = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE)
|
||||
prefix = m.group(0) if m else ""
|
||||
return prefix + f"{random.randint(10, 99)} {random.randint(10, 99)} {random_digits(6)}"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Генератор фиктивных ФИО.
|
||||
|
||||
Формат: Фамилия И.О. (фамилия полностью, имя и отчество — инициалы).
|
||||
"""
|
||||
|
||||
import random
|
||||
from ..config import RU_SURNAMES, RU_NAMES, RU_PATRONYMICS
|
||||
|
||||
|
||||
def generate_person(_: str) -> str:
|
||||
"""Сгенерировать фиктивное ФИО в формате «Фамилия И.О.».
|
||||
|
||||
Выбирает случайные фамилию, имя и отчество из словарей.
|
||||
Имя и отчество сокращаются до инициалов.
|
||||
|
||||
Args:
|
||||
_: Оригинальное ФИО (игнорируется)
|
||||
|
||||
Returns:
|
||||
Строка вида «Иванов А.И.»
|
||||
"""
|
||||
s = random.choice(RU_SURNAMES)
|
||||
n = random.choice(RU_NAMES)
|
||||
p = random.choice(RU_PATRONYMICS)
|
||||
return f"{s} {n[0]}.{p[0]}."
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Генератор фиктивного телефонного номера.
|
||||
|
||||
Формат: +7 (код) XXX-XX-XX
|
||||
Код выбирается случайно из списка реальных российских кодов.
|
||||
"""
|
||||
|
||||
import random
|
||||
from ..random_utils import random_digits
|
||||
|
||||
|
||||
def generate_phone(_: str) -> str:
|
||||
"""Сгенерировать фиктивный российский телефонный номер.
|
||||
|
||||
Args:
|
||||
_: Оригинальный номер (игнорируется, нужен для единого интерфейса)
|
||||
|
||||
Returns:
|
||||
Строка в формате +7 (XXX) XXX-XX-XX
|
||||
"""
|
||||
code = random.choice(["495", "499", "812", "383", "343"])
|
||||
return f"+7 ({code}) {random_digits(3)}-{random_digits(2)}-{random_digits(2)}"
|
||||
+17
-59
@@ -10,7 +10,6 @@ LLM-клиент для DrHider.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -24,33 +23,8 @@ class LLMClient:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализировать клиент. httpx импортируется лениво.
|
||||
|
||||
Ведёт кумулятивные счётчики за всё время жизни клиента:
|
||||
tokens_prompt — суммарные входные токены
|
||||
tokens_completion — суммарные выходные токены
|
||||
llm_sec — суммарное время LLM-запросов (сек)
|
||||
|
||||
Плюс флаг текущей активности (для live-таймера в UI):
|
||||
llm_active — True пока идёт LLM-вызов
|
||||
llm_started — time.time() начала текущего LLM-вызова
|
||||
"""
|
||||
"""Инициализировать клиент. httpx импортируется лениво."""
|
||||
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 и вернуть текст ответа.
|
||||
@@ -72,35 +46,19 @@ class LLMClient:
|
||||
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
|
||||
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()
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
|
||||
+33
-176
@@ -8,8 +8,6 @@
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
@@ -21,39 +19,6 @@ 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:
|
||||
"""Двухпроходный обфускатор документов.
|
||||
|
||||
@@ -78,8 +43,6 @@ class TwoPassObfuscator:
|
||||
"""
|
||||
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
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@@ -87,180 +50,80 @@ class TwoPassObfuscator:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
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]]:
|
||||
self, files: List[Tuple[str, bytes, str]]
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Обфусцировать список файлов.
|
||||
|
||||
Все форматы → 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}.
|
||||
content_type — MIME-тип (может быть пустой строкой)
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string, meta):
|
||||
zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
|
||||
(zip_bytes, csv_string):
|
||||
zip_bytes — ZIP-архив с обфусцированными файлами + mapping.csv
|
||||
csv_string — содержимое mapping.csv как строка
|
||||
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
|
||||
при прерывании (частичный результат)
|
||||
"""
|
||||
# ── Предобработка: распаковать ZIP + уникализировать имена ──
|
||||
# ── Предобработка: распаковать ZIP, конвертировать PDF ──
|
||||
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)
|
||||
files = extractor.convert_pdfs_to_docx(files)
|
||||
|
||||
try:
|
||||
# ── Проход 1: сбор сущностей ──
|
||||
# Извлекаем Markdown из каждого файла
|
||||
all_texts: Dict[str, str] = {}
|
||||
total = len(files)
|
||||
file_times: List[float] = [0.0] * total
|
||||
skipped: set = set() # имена файлов, которые не удалось обработать (битые)
|
||||
all_docx: Dict[str, object] = {}
|
||||
|
||||
# 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
|
||||
for fname, content, ctype in files:
|
||||
text, doc = extractor.extract_text(fname, content, ctype)
|
||||
all_texts[fname] = text
|
||||
if doc is not None:
|
||||
all_docx[fname] = doc
|
||||
|
||||
# Regex-сканирование (быстрое, локальное)
|
||||
if text and not text.startswith("[DOC binary"):
|
||||
scanner.scan_regex(text, self._mapping, self._counters)
|
||||
file_times[i] += time.time() - t0
|
||||
if text and text != "[DOC binary — not parsed]":
|
||||
scanner.scan_regex(text, self._mapping)
|
||||
|
||||
# Объём извлечённого текста — для оценки времени (глобальной 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
|
||||
# LLM-сканирование (медленное, сетевое — только если есть клиент)
|
||||
if self._llm_client:
|
||||
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client)
|
||||
|
||||
# Предсортировать ключи один раз (по убыванию длины)
|
||||
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()
|
||||
for fname, content, ctype in files:
|
||||
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
|
||||
# .doc — бинарный формат, оставляем как есть
|
||||
pass
|
||||
elif fname in all_docx:
|
||||
# DOCX: замена с сохранением форматирования
|
||||
obf_content = replacer.replace_in_docx(
|
||||
all_docx[fname], self._mapping, self._sorted_keys
|
||||
)
|
||||
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
|
||||
# Plain text / PDF-текст: простая замена
|
||||
txt = all_texts.get(fname, '')
|
||||
obf_content = replacer.replace_in_text(
|
||||
txt, fname, self._mapping, self._sorted_keys
|
||||
)
|
||||
# Имя файла: заменить расширение на .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
|
||||
return zip_data, csv_str
|
||||
|
||||
finally:
|
||||
# Очистка состояния (обфускатор может использоваться повторно)
|
||||
self._mapping.clear()
|
||||
self._sorted_keys.clear()
|
||||
self._compiled_re = None
|
||||
self._counters.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -268,10 +131,8 @@ class TwoPassObfuscator:
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
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]]:
|
||||
files: List[Tuple[str, bytes, str]], llm_client=None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Обфусцировать список файлов — удобная функция.
|
||||
|
||||
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
|
||||
@@ -279,13 +140,9 @@ def obfuscate_files(
|
||||
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)
|
||||
(zip_bytes, csv_string)
|
||||
"""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files, progress_cb=progress_cb,
|
||||
cancel_event=cancel_event, file_progress=file_progress)
|
||||
return obf.obfuscate(files)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Утилиты для генерации случайных строк.
|
||||
|
||||
Используются генераторами фиктивных значений для создания
|
||||
случайных цифр и букв в номерах/реквизитах.
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
def random_digits(n: int) -> str:
|
||||
"""Сгенерировать строку из n случайных цифр.
|
||||
|
||||
Args:
|
||||
n: Количество цифр
|
||||
|
||||
Returns:
|
||||
Строка из n случайных цифр (0-9)
|
||||
"""
|
||||
return ''.join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
|
||||
def random_letters(n: int) -> str:
|
||||
"""Сгенерировать строку из n случайных строчных латинских букв.
|
||||
|
||||
Args:
|
||||
n: Количество букв
|
||||
|
||||
Returns:
|
||||
Строка из n случайных букв (a-z)
|
||||
"""
|
||||
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
|
||||
+33
-79
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
Проход 2: замена сущностей в документах.
|
||||
|
||||
Два метода:
|
||||
1. apply_replacements — замена в Markdown/plain-тексте (основной)
|
||||
2. replace_in_docx — замена в DOCX с сохранением форматирования
|
||||
(для опционального DOCX-выхода, будет добавлен позже)
|
||||
Три метода:
|
||||
1. _apply_replacements — замена в plain-тексте (ядро)
|
||||
2. _replace_in_docx — замена в DOCX с сохранением форматирования
|
||||
3. _replace_in_text — замена в plain-тексте → bytes
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -12,52 +12,7 @@ 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:
|
||||
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str:
|
||||
"""Применить все замены из словаря mapping к строке.
|
||||
|
||||
Ключи применяются в порядке убывания длины (sorted_keys).
|
||||
@@ -71,42 +26,22 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str
|
||||
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)
|
||||
replacement = mapping[original]
|
||||
|
||||
# Если сущность обрамлена буквами/цифрами — используем границы слова
|
||||
if original and original[0].isalnum() and original[-1].isalnum():
|
||||
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
|
||||
else:
|
||||
pattern = re.escape(original)
|
||||
|
||||
result = re.sub(pattern, replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
@@ -164,3 +99,22 @@ def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> byt
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def replace_in_text(text: str, fname: str, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
|
||||
"""Заменить сущности в plain-тексте.
|
||||
|
||||
Для PDF и прочих нетекстовых форматов — отдаём текст.
|
||||
(MVP: без сохранения форматирования PDF).
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
fname: Имя файла (для будущего использования — разные форматы)
|
||||
mapping: Словарь замен
|
||||
sorted_keys: Ключи mapping по убыванию длины
|
||||
|
||||
Returns:
|
||||
Бинарное содержимое с заменами (UTF-8)
|
||||
"""
|
||||
replaced = apply_replacements(text, mapping, sorted_keys)
|
||||
return replaced.encode('utf-8')
|
||||
|
||||
+79
-227
@@ -5,290 +5,142 @@
|
||||
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
|
||||
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
|
||||
|
||||
Замена: гибридные токены — читаемый шаблон + уникальный номер.
|
||||
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
|
||||
LLM САМА определяет какие типы сущностей присутствуют в документе.
|
||||
Никакого фиксированного списка типов.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict
|
||||
|
||||
from .config import (
|
||||
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
|
||||
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
|
||||
)
|
||||
from .config import ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN
|
||||
from .generators import ENTITY_GENERATORS, FALLBACK_GENERATOR
|
||||
from .generators.company import generate_company
|
||||
from .generators.person import generate_person
|
||||
|
||||
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 гарантирует согласованность).
|
||||
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
|
||||
def _get_generator(entity_type: str):
|
||||
"""Получить генератор по типу сущности. Если тип неизвестен — fallback.
|
||||
|
||||
Args:
|
||||
entity_type: Тип сущности (person_name, company, phone...)
|
||||
counters: Глобальные счётчики {prefix_key: count}
|
||||
entity_type: Строковый идентификатор типа (например, "phone", "person_name")
|
||||
|
||||
Returns:
|
||||
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
|
||||
Функция-генератор: (str) -> str
|
||||
"""
|
||||
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")
|
||||
return ENTITY_GENERATORS.get(entity_type, FALLBACK_GENERATOR)
|
||||
|
||||
|
||||
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
|
||||
def scan_regex(text: str, mapping: Dict[str, str]) -> None:
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен.
|
||||
|
||||
Замена: гибридные токены (шаблон + номер).
|
||||
Ищет в тексте:
|
||||
- Сущности из ENTITY_PATTERNS (телефоны, email, ИНН, ОГРН, КПП, БИК, счета, паспорта)
|
||||
- Названия компаний (кроме «НУБЕС» — это Исполнитель, не заменяем)
|
||||
- ФИО в формате «Фамилия И.О.»
|
||||
|
||||
Найденные значения добавляются в mapping: оригинал → фиктивное значение.
|
||||
Если сущность уже есть в mapping — не перезаписываем (согласованность).
|
||||
|
||||
Args:
|
||||
text: Текст документа для сканирования
|
||||
mapping: Словарь замен (мутабельный, пополняется)
|
||||
counters: Глобальные счётчики токенов (мутабельный)
|
||||
"""
|
||||
# ── Сущности по regex-паттернам ──
|
||||
# ── Сущности по regex-паттернам (телефоны, email, реквизиты) ──
|
||||
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)
|
||||
|
||||
# Генератор: специализированный (если есть) или fallback
|
||||
generator = _get_generator(entity_type)
|
||||
mapping[original] = generator(original)
|
||||
|
||||
# ── Названия компаний ──
|
||||
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)
|
||||
|
||||
# ── ФИО ──
|
||||
# «НУБЕС» — Исполнитель, НЕ заменяем
|
||||
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
mapping[original] = generate_company(original)
|
||||
|
||||
# ── ФИО (Фамилия И.О.) ──
|
||||
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)
|
||||
mapping[original] = generate_person(original)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LLM-NER: пофайловый чанкинг (Sonnet-схема)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], llm_client) -> None:
|
||||
"""LLM NER для обнаружения имён, адресов, паспортных данных.
|
||||
|
||||
class CancelRequested(Exception):
|
||||
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
|
||||
Отправляет объединённый текст всех файлов в LLM, получает JSON-список
|
||||
найденных сущностей, добавляет их в словарь замен.
|
||||
|
||||
# Приоритет границ для чанков (от предпочтительных к жёстким)
|
||||
_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-найденное не дублируется.
|
||||
Отправляются первые 3000 символов каждого файла (экономия токенов).
|
||||
Общий размер промпта ограничен 8000 символами.
|
||||
|
||||
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
|
||||
# Формируем комбинированный текст: имя файла + первые 3000 символов
|
||||
combined = "\n\n---FILE---\n\n".join(
|
||||
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
|
||||
)
|
||||
|
||||
for fname, full_text in file_items:
|
||||
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise CancelRequested()
|
||||
# ── Универсальный промпт — без фиксированного списка типов ──
|
||||
prompt = (
|
||||
"You are a PII (Personally Identifiable Information) detector. "
|
||||
"Find ALL sensitive or private information in the text below.\n\n"
|
||||
"Return a JSON array. Each item must have:\n"
|
||||
' - "type": short snake_case label describing the entity\n'
|
||||
' (e.g. person_name, phone, email, address, inn, company, passport,\n'
|
||||
' contract_number, employee_id, bank_account — WHATEVER you see)\n'
|
||||
' - "value": the EXACT string from the text (copy verbatim)\n\n'
|
||||
"Rules:\n"
|
||||
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
|
||||
"- If same value appears multiple times — include only once\n"
|
||||
"- Invent the type label yourself based on what you see\n"
|
||||
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
|
||||
f"Text:\n{combined[:8000]}"
|
||||
)
|
||||
|
||||
if not full_text or full_text.startswith("[DOC binary"):
|
||||
continue
|
||||
chunks = split_into_chunks(full_text)
|
||||
try:
|
||||
# Отправляем запрос в LLM
|
||||
raw = llm_client.complete(prompt)
|
||||
|
||||
if file_progress:
|
||||
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
|
||||
# Чистим markdown-обёртку (если LLM вернула ```json ... ```)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
|
||||
t0 = time.time()
|
||||
entities = json.loads(raw)
|
||||
|
||||
# Параллельные вызовы 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:
|
||||
# Добавляем найденные сущности в mapping
|
||||
for ent in entities:
|
||||
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))
|
||||
# Тип — любой (LLM сама придумала)
|
||||
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
|
||||
|
||||
# Генератор: специализированный (если тип совпал) или fallback
|
||||
generator = _get_generator(ent_type)
|
||||
mapping[val] = generator(val)
|
||||
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
flask
|
||||
waitress
|
||||
python-docx
|
||||
pdfplumber
|
||||
httpx
|
||||
|
||||
+2
-53
@@ -12,7 +12,6 @@ DrHider — Managed Flask приложение на платформе Штур
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from flask import Flask
|
||||
|
||||
# Добавляем корень проекта в sys.path для импорта пакета drhider
|
||||
@@ -21,81 +20,31 @@ if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "0.0.80"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Настроить логирование. Уровень из env LOG_LEVEL (DEBUG/INFO/WARNING).
|
||||
|
||||
Логи пишутся в stderr (для локального запуска) И в файл /tmp/drhider.log
|
||||
(для кубера, где stdout/stderr контейнера может не попадать в kubectl logs).
|
||||
Путь файла можно переопределить env LOG_FILE.
|
||||
"""
|
||||
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
log_file = os.environ.get("LOG_FILE", "/tmp/drhider.log")
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers.clear()
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(level)
|
||||
sh.setFormatter(fmt)
|
||||
root.addHandler(sh)
|
||||
try:
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setLevel(level)
|
||||
fh.setFormatter(fmt)
|
||||
root.addHandler(fh)
|
||||
except Exception as e: # файл не открылся — не падаем
|
||||
sys.stderr.write(f"[app] cannot open log file {log_file}: {e}\n")
|
||||
for name in ("drhider", "app", "routes", "session", __name__):
|
||||
logging.getLogger(name).setLevel(level)
|
||||
log = logging.getLogger("app")
|
||||
log.info("Logging configured, level=%s, version=%s, file=%s", level_name, VERSION, log_file)
|
||||
VERSION = "2.0.0"
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Создать и настроить Flask-приложение.
|
||||
|
||||
Штурвал запускает из директории site/:
|
||||
from app import app
|
||||
|
||||
Поэтому routes/ доступен как обычный подпакет.
|
||||
|
||||
Returns:
|
||||
Экземпляр Flask с зарегистрированными blueprint'ами.
|
||||
"""
|
||||
setup_logging()
|
||||
app = Flask(__name__)
|
||||
|
||||
# Конфигурация
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
# Регистрируем blueprint'ы
|
||||
from routes import register_routes
|
||||
|
||||
register_routes(app)
|
||||
|
||||
# Запрещаем кэширование — F5 всегда грузит свежую страницу
|
||||
@app.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Экземпляр приложения — точка входа для Штурвала
|
||||
app = create_app()
|
||||
|
||||
# Штурвал запускает: cd site && python app.py
|
||||
if __name__ == "__main__":
|
||||
from waitress import serve
|
||||
serve(app, host="0.0.0.0", port=5000, threads=8)
|
||||
|
||||
|
||||
|
||||
|
||||
+6
-11
@@ -1,7 +1,12 @@
|
||||
"""
|
||||
Регистрация всех blueprint'ов приложения.
|
||||
|
||||
Импортируется из site/app.py при создании Flask-приложения.
|
||||
"""
|
||||
|
||||
from .main_bp import main_bp
|
||||
from .health_bp import health_bp
|
||||
from .api_bp import api_bp
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
@@ -13,13 +18,3 @@ def register_routes(app):
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": "https://upload.sinefo.ru/drhider-upload/",
|
||||
"maxFileBytes": 50 * 1024 * 1024,
|
||||
"maxSessionBytes": 500 * 1024 * 1024,
|
||||
"ttlSeconds": 30 * 60,
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
}))
|
||||
|
||||
+48
-337
@@ -1,362 +1,73 @@
|
||||
"""
|
||||
Blueprint: API DrHider.
|
||||
Blueprint: API обфускации (POST /api/drhider).
|
||||
|
||||
Five endpoints:
|
||||
- POST /api/upload — upload one file -> {session_id}
|
||||
- GET /api/process_stream/<sid> — SSE: process files, per-file progress
|
||||
- POST /api/process/<sid> — process all session files -> {status:"done"} (legacy)
|
||||
- GET /api/download/<sid> — download ZIP (with timestamp name)
|
||||
- GET /api/csv/<sid> — download CSV separately
|
||||
Принимает multipart/form-data с файлами, возвращает ZIP-архив
|
||||
с обфусцированными документами.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
import traceback
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||
from flask import Blueprint, request, send_file, jsonify
|
||||
|
||||
from drhider import obfuscate_files, LLMClient
|
||||
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count,
|
||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||
request_cancel, get_cancel_event)
|
||||
from upload.backend.upload_refs import safe_name
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: API DrHider
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
log = logging.getLogger("routes.api_bp")
|
||||
|
||||
def _disconnect_exceptions():
|
||||
"""Исключения, означающие отключение клиента SSE."""
|
||||
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
|
||||
# Максимальный размер тела запроса: 200 MB
|
||||
MAX_BODY = 200 * 1024 * 1024
|
||||
|
||||
|
||||
@api_bp.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
"""Upload files to session (один или несколько)."""
|
||||
sid = request.form.get("session", "")
|
||||
if not sid:
|
||||
sid = create_session()
|
||||
@api_bp.route("/drhider", methods=["POST"])
|
||||
def drhider():
|
||||
"""Обфускация документов.
|
||||
|
||||
Принимает multipart/form-data с полем 'files' (один или несколько файлов).
|
||||
Возвращает ZIP-архив с обфусцированными документами + mapping.csv.
|
||||
|
||||
Поддерживаемые форматы:
|
||||
.docx, .pdf, .txt, .doc (бинарный — без изменений)
|
||||
.zip (распаковывается, содержимое обфусцируется)
|
||||
|
||||
Returns:
|
||||
200: ZIP-архив (application/zip)
|
||||
400: {"ok": false, "error": "..."}
|
||||
500: {"ok": false, "error": "..."}
|
||||
"""
|
||||
# ── Получаем файлы из запроса ──
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
log.warning("upload: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No file"}), 400
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
added = 0
|
||||
had_unnamed = False
|
||||
# Формируем список для obfuscate_files: [(name, bytes, mimetype), ...]
|
||||
files = []
|
||||
for f in uploaded:
|
||||
name = safe_name(f.filename)
|
||||
if not name:
|
||||
had_unnamed = True
|
||||
continue
|
||||
data = f.read()
|
||||
log.info("upload: sid=%s file=%r size=%d", sid, name, len(data))
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
log.warning("upload: file exceeds %dMB, skipped sid=%s file=%r size=%d",
|
||||
MAX_FILE_BYTES // (1024 * 1024), sid, name, len(data))
|
||||
continue
|
||||
if not add_file(sid, name, data):
|
||||
log.warning("upload: session not found/limit, sid=%s file=%r", sid, f.filename)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
added += 1
|
||||
if f.filename:
|
||||
files.append((f.filename, f.read(), f.mimetype or ""))
|
||||
|
||||
if added == 0:
|
||||
err = "No filename" if had_unnamed else "No file"
|
||||
log.warning("upload: %s, sid=%s", err, sid)
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
|
||||
log.info("upload: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||
|
||||
|
||||
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
||||
def session_files(sid):
|
||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
return jsonify({
|
||||
"ok": True, "count": len(files),
|
||||
"files": [{"name": n, "size": len(b)} for n, b in files],
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route("/cancel/<sid>", methods=["POST"])
|
||||
def cancel(sid):
|
||||
"""Запросить мягкое прерывание обработки сессии.
|
||||
|
||||
Воркер останавливается на ближайшей границе файла (текущий добирается),
|
||||
собирает частичный результат (готовые файлы + mapping) и шлёт SSE-событие `cancelled`.
|
||||
"""
|
||||
if request_cancel(sid):
|
||||
log.info("cancel: requested sid=%s", sid)
|
||||
return jsonify({"ok": True}), 200
|
||||
log.warning("cancel: session not found sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
|
||||
|
||||
@api_bp.route("/process_stream/<sid>", methods=["GET"])
|
||||
def process_stream(sid):
|
||||
"""SSE: process all session files, streaming per-file progress.
|
||||
|
||||
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
|
||||
согласованные токены). Обработка идёт в отдельном потоке; прогресс
|
||||
передаётся через очередь. Разрыв соединения клиента корректно
|
||||
перехватывается и останавливает генератор (и воркер — через cancel_event).
|
||||
"""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
|
||||
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
|
||||
pause_ttl(sid)
|
||||
|
||||
def generate():
|
||||
llm = LLMClient()
|
||||
q = queue.Queue()
|
||||
cancel = threading.Event() # локальный: разрыв клиента (стоп heartbeat)
|
||||
cancel_event = get_cancel_event(sid) # из сессии: мягкая отмена (кнопка «Прервать»)
|
||||
|
||||
# Состояние для глобальной ETA (символы)
|
||||
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
|
||||
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
|
||||
|
||||
def progress(phase, idx, total_, name, elapsed):
|
||||
q.put(("progress", phase, idx, name, total_, elapsed))
|
||||
|
||||
# Состояние для per-file ETA (чанки)
|
||||
fstate = {"t0": 0.0, "total": 0, "done": 0}
|
||||
|
||||
def file_progress(event, fname, **fields):
|
||||
if event == "file_start":
|
||||
fstate["t0"] = time.time()
|
||||
fstate["total"] = fields.get("chunks", 0)
|
||||
fstate["done"] = 0
|
||||
elif event == "file_chunk":
|
||||
fstate["done"] = fields.get("chunks_done", 0)
|
||||
elapsed = time.time() - fstate["t0"]
|
||||
rate = fstate["done"] / elapsed if elapsed > 0 else 0
|
||||
rem = fstate["total"] - fstate["done"]
|
||||
if rate > 0:
|
||||
fields["eta_sec"] = max(0, round(rem / rate))
|
||||
q.put(("file", event, fname, fields))
|
||||
|
||||
def worker():
|
||||
log.info("worker: start sid=%s files=%d", sid, len(all_files))
|
||||
t0 = datetime.utcnow()
|
||||
try:
|
||||
zip_data, csv_str, meta = obfuscate_files(
|
||||
all_files, llm_client=llm, progress_cb=progress,
|
||||
cancel_event=cancel_event, file_progress=file_progress,
|
||||
)
|
||||
stats = {
|
||||
"tokens": llm.tokens_total,
|
||||
"llm_sec": round(llm.llm_sec, 1),
|
||||
}
|
||||
dt = (datetime.utcnow() - t0).total_seconds()
|
||||
if meta and meta.get("cancelled"):
|
||||
log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
|
||||
sid, dt, meta.get("processed", 0), meta.get("total", 0))
|
||||
q.put(("cancelled", zip_data, csv_str, stats, meta))
|
||||
else:
|
||||
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
|
||||
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
|
||||
q.put(("result", zip_data, csv_str, stats))
|
||||
except Exception as e:
|
||||
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
|
||||
q.put(("error", repr(e)))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
log.debug("process_stream: worker thread started sid=%s", sid)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
evt = q.get(timeout=1)
|
||||
except queue.Empty:
|
||||
if cancel.is_set():
|
||||
log.info("process_stream: cancelled sid=%s (generator exit)", sid)
|
||||
return
|
||||
# Heartbeat: живая статистика LLM + глобальная ETA
|
||||
tokens = llm.tokens_total
|
||||
elapsed = llm.llm_elapsed_now()
|
||||
eta_sec = None
|
||||
if eta["total_chars"] > 0 and tokens > 0 and elapsed > 0:
|
||||
est_total_tokens = eta["total_chars"] * _TOKENS_PER_CHAR
|
||||
rate = tokens / elapsed
|
||||
if rate > 0:
|
||||
eta_sec = max(0, int((est_total_tokens - tokens) / rate))
|
||||
done_chars = eta["done_chars"]
|
||||
if eta["cur_total"] > 0:
|
||||
done_chars += int(eta["cur_chars"] * (eta["cur_done"] / eta["cur_total"]))
|
||||
try:
|
||||
yield (
|
||||
f"event: llm\n"
|
||||
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(elapsed, 1), 'tokens': tokens, 'eta_sec': eta_sec, 'done_chars': done_chars, 'total_chars': eta['total_chars']})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
continue
|
||||
|
||||
kind = evt[0]
|
||||
|
||||
if kind == "progress":
|
||||
_, phase, idx, name, total_, elapsed = evt
|
||||
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
|
||||
phase, idx, name, elapsed, sid)
|
||||
try:
|
||||
yield (
|
||||
f"event: {phase}\n"
|
||||
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
|
||||
elif kind == "file":
|
||||
_, event, fname, fields = evt
|
||||
# Обновляем состояние для глобальной ETA
|
||||
if event == "extract_done":
|
||||
eta["total_chars"] = fields.get("total_chars", 0)
|
||||
elif event == "file_start":
|
||||
eta["cur_chars"] = fields.get("chars", 0)
|
||||
eta["cur_total"] = fields.get("chunks", 0)
|
||||
eta["cur_done"] = 0
|
||||
elif event == "file_chunk":
|
||||
eta["cur_done"] = fields.get("chunks_done", 0)
|
||||
elif event == "file_done":
|
||||
eta["done_chars"] += eta["cur_chars"]
|
||||
eta["cur_chars"] = 0
|
||||
eta["cur_total"] = 0
|
||||
eta["cur_done"] = 0
|
||||
try:
|
||||
yield (
|
||||
f"event: {event}\n"
|
||||
f"data: {json.dumps({'name': fname, **fields})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on file event sid=%s err=%r", sid, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
|
||||
elif kind == "result":
|
||||
_, zip_data, csv_str, stats = evt
|
||||
log.info("process_stream: result sid=%s, storing result", sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
count = 0
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
count = len([n for n in zf.namelist() if n != "mapping.csv"])
|
||||
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
|
||||
try:
|
||||
yield (
|
||||
f"event: complete\n"
|
||||
f"data: {json.dumps({'total': count, **stats})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
|
||||
elif kind == "cancelled":
|
||||
_, zip_data, csv_str, stats, meta = evt
|
||||
log.info("process_stream: cancelled sid=%s, storing partial result", sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
try:
|
||||
yield (
|
||||
f"event: cancelled\n"
|
||||
f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
|
||||
elif kind == "error":
|
||||
_, msg = evt
|
||||
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
|
||||
try:
|
||||
yield f"event: proc_error\ndata: {json.dumps({'error': msg})}\n\n"
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
finally:
|
||||
resume_ttl(sid)
|
||||
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/process/<sid>", methods=["POST"])
|
||||
def process(sid):
|
||||
"""Process all session files -> ZIP (единым вызовом, общий mapping)."""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
# ── Обфускация ──
|
||||
try:
|
||||
# Создаём LLM-клиент для NER-сканирования
|
||||
llm = LLMClient()
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
pause_ttl(sid)
|
||||
try:
|
||||
zip_data, csv_str, _ = obfuscate_files(all_files, llm_client=llm)
|
||||
finally:
|
||||
resume_ttl(sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
return jsonify({"ok": True, "status": "done"})
|
||||
|
||||
# Вызываем ядро обфускации
|
||||
zip_data, _csv = obfuscate_files(files, llm_client=llm)
|
||||
|
||||
# Отдаём ZIP-архив
|
||||
return send_file(
|
||||
io.BytesIO(zip_data),
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
download_name="drhider_output.zip",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Логируем полный трейсбек на сервере
|
||||
traceback.print_exc()
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/download/<sid>", methods=["GET"])
|
||||
def download(sid):
|
||||
"""Download result and cleanup session."""
|
||||
zip_data = get_result(sid)
|
||||
if zip_data is None:
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
return send_file(io.BytesIO(zip_data), mimetype="application/zip",
|
||||
as_attachment=True, download_name=f"drhider_{ts}.zip")
|
||||
|
||||
|
||||
@api_bp.route("/csv/<sid>", methods=["GET"])
|
||||
def csv_download(sid):
|
||||
"""Download CSV separately."""
|
||||
csv_str = get_csv(sid)
|
||||
if csv_str is None:
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
buf = io.BytesIO()
|
||||
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
|
||||
buf.seek(0)
|
||||
return send_file(buf, mimetype="text/csv",
|
||||
as_attachment=True, download_name=f"mapping_{ts}.csv")
|
||||
|
||||
+1
-26
@@ -4,19 +4,12 @@ Blueprint: главная страница (GET /).
|
||||
Отдаёт HTML-интерфейс DrHider.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, render_template, current_app, send_from_directory
|
||||
from flask import Blueprint, render_template, current_app
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: главная страница
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
|
||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend")
|
||||
_DIST_DIR = os.path.join(_ROOT_DIR, "site", "static", "dist")
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@@ -28,21 +21,3 @@ def index():
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return render_template("index.html", version=version)
|
||||
|
||||
|
||||
@main_bp.route("/upload/<path:filename>")
|
||||
def upload_frontend(filename):
|
||||
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
|
||||
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный бандл из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/dist/<path:filename>")
|
||||
def dist_files(filename):
|
||||
"""Отдаёт файлы из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
|
||||
Vendored
-1194
File diff suppressed because it is too large
Load Diff
Vendored
-1218
File diff suppressed because it is too large
Load Diff
+66
-25
@@ -1,25 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
sodipodi:docname="favicon.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="5.6"
|
||||
inkscape:cx="91.428571"
|
||||
inkscape:cy="54.910714"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
width="120px" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Calque 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-80.000001,-1018.2599)">
|
||||
<path
|
||||
style="fill:#004d00;stroke-width:0.100353;fill-opacity:1"
|
||||
d="m 144.12346,1127.3527 c -14.7384,-15.4935 -17.30029,-23.203 -14.91709,-44.8912 1.78418,-16.2372 1.43961,-18.7434 -3.2949,-23.9623 -4.60141,-5.0725 -4.72472,-5.8285 -0.95017,-5.8285 5.32989,0 5.67331,-6.0284 0.58406,-10.2519 -4.3155,-3.5819 -10.08626,-19.468 -8.64375,-23.7956 1.09412,-3.2824 21.29169,27.0158 23.93661,35.9068 0.94001,3.1601 8.83542,12.1943 17.54536,20.0767 11.55761,10.4589 14.47966,14.331 10.81518,14.331 -2.7616,0 -9.48553,-3.0338 -14.94204,-6.7417 -6.36215,-4.323 -10.81609,-5.8461 -12.41642,-4.2461 -1.60033,1.6005 3.29311,7.7342 13.64167,17.0989 8.87547,8.0322 15.48529,15.2553 14.68853,16.052 -0.79676,0.7967 -7.93147,-2.6984 -15.85488,-7.7667 -16.57177,-10.6009 -17.2489,-10.7849 -17.23587,-4.6824 0.0132,5.7346 7.77953,18.5635 16.5486,27.3327 3.71587,3.7157 6.75613,7.9746 6.75613,9.4643 0,5.5255 -6.27497,2.4016 -16.26102,-8.096 z"
|
||||
id="path3974"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
+168
-917
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
"""Переиспользуемые слои загрузки файлов.
|
||||
|
||||
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||
@@ -1,38 +0,0 @@
|
||||
"""In-memory хранилище сессий с TTL и лимитами.
|
||||
|
||||
Каждая операция — в отдельном файле, общее состояние — в state.py.
|
||||
Импорт как единый пакет:
|
||||
|
||||
from upload.backend.session import create_session, add_file, get_files
|
||||
"""
|
||||
|
||||
from .create_session import create_session
|
||||
from .add_file import add_file
|
||||
from .get_files import get_files, file_count
|
||||
from .store_result import store_result, get_result
|
||||
from .store_csv import store_csv, get_csv
|
||||
from .ttl import touch, pause_ttl, resume_ttl
|
||||
from .cancel import request_cancel, get_cancel_event
|
||||
from .cleanup import cleanup
|
||||
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
|
||||
|
||||
__all__ = [
|
||||
"create_session",
|
||||
"add_file",
|
||||
"get_files",
|
||||
"file_count",
|
||||
"store_result",
|
||||
"get_result",
|
||||
"store_csv",
|
||||
"get_csv",
|
||||
"touch",
|
||||
"pause_ttl",
|
||||
"resume_ttl",
|
||||
"request_cancel",
|
||||
"get_cancel_event",
|
||||
"cleanup",
|
||||
"configure",
|
||||
"TTL_SECONDS",
|
||||
"MAX_FILE_BYTES",
|
||||
"MAX_SESSION_BYTES",
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
|
||||
|
||||
from . import state
|
||||
|
||||
|
||||
def add_file(sid: str, filename: str, content: bytes) -> bool:
|
||||
"""Добавить файл в сессию.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
filename: Имя файла
|
||||
content: Бинарное содержимое
|
||||
|
||||
Returns:
|
||||
True если добавлено; False если сессии нет или превышен лимит сессии.
|
||||
"""
|
||||
with state._lock:
|
||||
s = state._sessions.get(sid)
|
||||
if not s:
|
||||
return False
|
||||
total = sum(len(c) for _, c in s["files"])
|
||||
if total + len(content) > state.MAX_SESSION_BYTES:
|
||||
return False # превышен суммарный лимит сессии
|
||||
s["files"].append((filename, content))
|
||||
return True
|
||||
@@ -1,27 +0,0 @@
|
||||
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def request_cancel(sid: str) -> bool:
|
||||
"""Запросить мягкое прерывание обработки сессии.
|
||||
|
||||
Returns:
|
||||
True если сессия существует и отмена запрошена, False если нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return False
|
||||
s["cancel"].set()
|
||||
return True
|
||||
|
||||
|
||||
def get_cancel_event(sid: str) -> Optional[threading.Event]:
|
||||
"""Получить событие отмены сессии (или None, если сессии нет)."""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return s["cancel"] if s else None
|
||||
@@ -1,11 +0,0 @@
|
||||
"""cleanup — удалить сессию."""
|
||||
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def cleanup(sid: str):
|
||||
"""Удалить сессию и остановить её TTL-таймер."""
|
||||
with _lock:
|
||||
s = _sessions.pop(sid, None)
|
||||
if s and s.get("timer"):
|
||||
s["timer"].cancel()
|
||||
@@ -1,23 +0,0 @@
|
||||
"""create_session — создать новую сессию."""
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from .state import _sessions, _lock, _start_timer
|
||||
|
||||
|
||||
def create_session() -> str:
|
||||
"""Создать новую сессию.
|
||||
|
||||
Returns:
|
||||
Уникальный идентификатор сессии (UUID).
|
||||
"""
|
||||
sid = uuid.uuid4().hex
|
||||
with _lock:
|
||||
_sessions[sid] = {
|
||||
"files": [],
|
||||
"result": None,
|
||||
"cancel": threading.Event(),
|
||||
"timer": _start_timer(sid),
|
||||
}
|
||||
return sid
|
||||
@@ -1,23 +0,0 @@
|
||||
"""get_files / file_count — чтение файлов сессии."""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
|
||||
"""Получить все файлы сессии.
|
||||
|
||||
Returns:
|
||||
[(filename, content), ...] или None если сессия не найдена.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return list(s["files"]) if s else None
|
||||
|
||||
|
||||
def file_count(sid: str) -> int:
|
||||
"""Количество файлов в сессии."""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return len(s["files"]) if s else 0
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
|
||||
|
||||
Единая точка хранения состояния — все операции импортируют её.
|
||||
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
# TTL сессии: 30 минут
|
||||
TTL_SECONDS = 30 * 60
|
||||
|
||||
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
|
||||
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
|
||||
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
|
||||
|
||||
_sessions: dict = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _start_timer(sid: str) -> threading.Timer:
|
||||
"""Запустить таймер автоочистки сессии через TTL."""
|
||||
|
||||
def _clean():
|
||||
with _lock:
|
||||
_sessions.pop(sid, None)
|
||||
|
||||
timer = threading.Timer(TTL_SECONDS, _clean)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return timer
|
||||
|
||||
|
||||
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
|
||||
ttl_seconds: int = None):
|
||||
"""Переопределить лимиты/TTL из конфига приложения (глобально).
|
||||
|
||||
None — оставить текущее значение.
|
||||
"""
|
||||
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
|
||||
if max_file_bytes is not None:
|
||||
MAX_FILE_BYTES = max_file_bytes
|
||||
if max_session_bytes is not None:
|
||||
MAX_SESSION_BYTES = max_session_bytes
|
||||
if ttl_seconds is not None:
|
||||
TTL_SECONDS = ttl_seconds
|
||||
@@ -1,30 +0,0 @@
|
||||
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def store_csv(sid: str, csv_str: str) -> bool:
|
||||
"""Сохранить CSV с таблицей замен.
|
||||
|
||||
Returns:
|
||||
True если сохранено, False если сессии нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return False
|
||||
s["csv"] = csv_str
|
||||
return True
|
||||
|
||||
|
||||
def get_csv(sid: str) -> Optional[str]:
|
||||
"""Получить CSV с таблицей замен.
|
||||
|
||||
Returns:
|
||||
Строка CSV или None если нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return s.get("csv") if s else None
|
||||
@@ -1,30 +0,0 @@
|
||||
"""store_result / get_result — сохранение и чтение результата обработки."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def store_result(sid: str, zip_data: bytes) -> bool:
|
||||
"""Сохранить результат обработки (ZIP-архив).
|
||||
|
||||
Returns:
|
||||
True если сохранено, False если сессии нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return False
|
||||
s["result"] = zip_data
|
||||
return True
|
||||
|
||||
|
||||
def get_result(sid: str) -> Optional[bytes]:
|
||||
"""Получить результат обработки.
|
||||
|
||||
Returns:
|
||||
ZIP-архив или None если сессия не найдена/результат не готов.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return s["result"] if s else None
|
||||
@@ -1,34 +0,0 @@
|
||||
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
|
||||
|
||||
from .state import _sessions, _lock, _start_timer
|
||||
|
||||
|
||||
def touch(sid: str):
|
||||
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
if s.get("timer"):
|
||||
s["timer"].cancel()
|
||||
s["timer"] = _start_timer(sid)
|
||||
|
||||
|
||||
def pause_ttl(sid: str):
|
||||
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if s and s.get("timer"):
|
||||
s["timer"].cancel()
|
||||
s["timer"] = None
|
||||
|
||||
|
||||
def resume_ttl(sid: str):
|
||||
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
if s.get("timer"):
|
||||
s["timer"].cancel()
|
||||
s["timer"] = _start_timer(sid)
|
||||
@@ -1,7 +0,0 @@
|
||||
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
|
||||
|
||||
from .blueprint import create_upload_refs_blueprint
|
||||
from .safe_name import safe_name
|
||||
from .pull_file import pull_file
|
||||
|
||||
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
|
||||
|
||||
Поведение:
|
||||
- safe_name (защита от path traversal)
|
||||
- SSRF-валидация url.startswith(vm_prefix)
|
||||
- лимит на один файл -> delete + skip
|
||||
- pull с ретраями через httpx stream
|
||||
- лимит сессии -> skip; отсутствие сессии -> 404
|
||||
- delete url с ВМ (best-effort)
|
||||
- поддержка пофайлового транзита и пачек
|
||||
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from urllib.parse import urlsplit
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
|
||||
from ..session import (create_session, add_file, get_files, file_count,
|
||||
MAX_FILE_BYTES, configure)
|
||||
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
|
||||
from .safe_name import safe_name
|
||||
from .pull_file import pull_file
|
||||
|
||||
log = logging.getLogger("upload.upload_refs")
|
||||
|
||||
|
||||
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
|
||||
"""Создать Blueprint с эндпоинтом upload_refs.
|
||||
|
||||
cfg (все ключи опциональны, есть дефолты):
|
||||
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
|
||||
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
|
||||
maxFileBytes (int) — лимит на один файл
|
||||
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
|
||||
ttlSeconds (int) — TTL сессии
|
||||
pullRetries (int) — ретраи pull
|
||||
pullRetryDelay (int) — пауза между ретраями (сек)
|
||||
pullTimeout (int) — таймаут одного GET pull
|
||||
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
|
||||
"""
|
||||
cfg = cfg or {}
|
||||
prefix = cfg.get("apiPrefix", "/api")
|
||||
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
|
||||
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
|
||||
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
|
||||
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
|
||||
pull_timeout = cfg.get("pullTimeout", 120)
|
||||
on_file_received = cfg.get("onFileReceived")
|
||||
|
||||
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
|
||||
configure(
|
||||
max_file_bytes=cfg.get("maxFileBytes"),
|
||||
max_session_bytes=cfg.get("maxSessionBytes"),
|
||||
ttl_seconds=cfg.get("ttlSeconds"),
|
||||
)
|
||||
|
||||
is_path_only_prefix = vm_prefix.startswith("/")
|
||||
|
||||
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
|
||||
|
||||
@bp.route("/upload_refs", methods=["POST"])
|
||||
def upload_refs():
|
||||
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
|
||||
|
||||
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
|
||||
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
|
||||
читается по частям (stream), кладётся в сессию. После успешного pull файл
|
||||
удаляется с ВМ (DELETE).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
sid = data.get("session") or create_session()
|
||||
refs = data.get("files") or []
|
||||
if not refs:
|
||||
log.warning("upload_refs: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
added = 0
|
||||
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
|
||||
try:
|
||||
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
|
||||
for ref in refs:
|
||||
name = safe_name(ref.get("name") or "")
|
||||
url = ref.get("url") or ""
|
||||
if not name or not url:
|
||||
continue
|
||||
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
|
||||
if is_path_only_prefix:
|
||||
if not urlsplit(url).path.startswith(vm_prefix):
|
||||
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
|
||||
continue
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
|
||||
else:
|
||||
if not url.startswith(vm_prefix):
|
||||
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
|
||||
continue
|
||||
# Лимит на один файл: сверх лимита — пропускаем
|
||||
if (ref.get("size") or 0) > max_file_bytes:
|
||||
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
|
||||
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
|
||||
try:
|
||||
client.delete(url)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Pull с ретраями
|
||||
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
|
||||
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
|
||||
if len(content) > max_file_bytes:
|
||||
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
|
||||
max_file_bytes // (1024 * 1024), sid, name, len(content))
|
||||
try:
|
||||
client.delete(url)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if not add_file(sid, name, content):
|
||||
if get_files(sid) is None:
|
||||
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
|
||||
try:
|
||||
client.delete(url)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Успешно добавлен в сессию — удаляем с ВМ-буфера
|
||||
try:
|
||||
client.delete(url)
|
||||
except Exception:
|
||||
pass
|
||||
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
|
||||
if callable(on_file_received):
|
||||
try:
|
||||
on_file_received(sid, name, content)
|
||||
except Exception as cb_err:
|
||||
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
|
||||
added += 1
|
||||
except Exception as e:
|
||||
log.error("upload_refs: pull error sid=%s: %r", sid, e)
|
||||
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
|
||||
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
|
||||
|
||||
return bp
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Параметры слоя 2 (бэк) по умолчанию.
|
||||
|
||||
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
|
||||
"""
|
||||
|
||||
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
|
||||
PULL_RETRIES = 3
|
||||
PULL_RETRY_DELAY = 2 # секунды между попытками
|
||||
|
||||
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
|
||||
VM_UPLOAD_PREFIX = "https://upload.sinefo.ru/drhider-upload/"
|
||||
@@ -1,39 +0,0 @@
|
||||
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .config import PULL_RETRIES, PULL_RETRY_DELAY
|
||||
|
||||
log = logging.getLogger("upload.upload_refs.pull")
|
||||
|
||||
|
||||
def pull_file(client, url: str, retries: int = PULL_RETRIES,
|
||||
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
|
||||
"""GET url с ретраями; читает по частям (stream).
|
||||
|
||||
Args:
|
||||
client: httpx.Client
|
||||
url: URL файла на ВМ-буфере.
|
||||
retries: число попыток.
|
||||
delay: пауза между попытками (сек).
|
||||
sid/name: для логирования (опционально).
|
||||
|
||||
Returns:
|
||||
Содержимое файла (bytes).
|
||||
|
||||
Raises:
|
||||
Последнюю ошибку попытки, если все ретраи не удались.
|
||||
"""
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
return b"".join(resp.iter_bytes())
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
|
||||
attempt + 1, retries, sid, name, e)
|
||||
time.sleep(delay)
|
||||
raise last_err if last_err else RuntimeError("pull failed")
|
||||
@@ -1,16 +0,0 @@
|
||||
"""safe_name — санитизация имени файла (защита от path traversal)."""
|
||||
|
||||
|
||||
def safe_name(name: str) -> str:
|
||||
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
|
||||
|
||||
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
|
||||
имя пустое или небезопасное.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
name = name.replace("\\", "/")
|
||||
parts = [p for p in name.split("/") if p and p != "."]
|
||||
if not parts or any(p == ".." for p in parts):
|
||||
return ""
|
||||
return "/".join(parts)
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { addFiles, onFilesChange } from './table/on_files_change.js';
|
||||
import { onFolderChange } from './table/on_folder_change.js';
|
||||
import { findNode, flattenFiles, render } from './table/render.js';
|
||||
import { esc } from './table/esc.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: 'Выбрать файлы',
|
||||
pickFolder: 'Выбрать папку',
|
||||
clear: 'Очистить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${bytes} B`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
|
||||
limits: {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMount(mount) {
|
||||
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
|
||||
if (!element) throw new Error('File picker mount element не найден');
|
||||
return element;
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
||||
labels: {
|
||||
...DEFAULTS.labels,
|
||||
...(config.labels || {}),
|
||||
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
|
||||
},
|
||||
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
|
||||
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.querySelector('style[data-file-picker]')) return;
|
||||
const style = document.createElement('style');
|
||||
style.dataset.filePicker = '';
|
||||
style.textContent = `
|
||||
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
||||
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
||||
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
||||
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
||||
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
||||
.file-picker .fp-count { min-height: 22px; }
|
||||
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
||||
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
||||
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
||||
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function buildMarkup(root, cfg) {
|
||||
const controls = cfg.layout.controls || [];
|
||||
const control = (name, id, label, className = '') => controls.includes(name)
|
||||
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
||||
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
|
||||
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
|
||||
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
|
||||
</div>
|
||||
<p class="fp-status" role="status"></p>
|
||||
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
||||
<p class="fp-count"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function notify(cfg, state) {
|
||||
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
|
||||
}
|
||||
|
||||
function removeMetadata(state, node) {
|
||||
if (node.kind === 'file') {
|
||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
||||
return;
|
||||
}
|
||||
node.children.forEach((child) => removeMetadata(state, child));
|
||||
}
|
||||
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return false;
|
||||
removeMetadata(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initFilePicker(config) {
|
||||
const cfg = normalizeConfig(config);
|
||||
const mount = resolveMount(cfg.mount);
|
||||
injectStyles();
|
||||
buildMarkup(mount, cfg);
|
||||
const root = mount.firstElementChild;
|
||||
const elements = {
|
||||
fileInputEl: root.querySelector('.fp-file-input'),
|
||||
folderInputEl: root.querySelector('.fp-folder-input'),
|
||||
statusEl: root.querySelector('.fp-status'),
|
||||
tableBodyEl: root.querySelector('.fp-table-body'),
|
||||
countEl: root.querySelector('.fp-count'),
|
||||
};
|
||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
||||
const listeners = [];
|
||||
const listen = (target, event, handler) => {
|
||||
target.addEventListener(event, handler);
|
||||
listeners.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
const redraw = () => render(state, elements, cfg);
|
||||
const filesChange = onFilesChange(state, cfg, elements);
|
||||
const folderChange = onFolderChange(state, cfg, elements);
|
||||
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
|
||||
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
|
||||
listen(elements.tableBodyEl, 'click', (event) => {
|
||||
const toggle = event.target.closest('[data-toggle]');
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
if (removeNode(state, remove.dataset.remove)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
|
||||
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
|
||||
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
redraw();
|
||||
return {
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
|
||||
getFiles: () => [...flattenFiles(state.nodes)],
|
||||
remove: (id) => {
|
||||
if (removeNode(state, id)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
render: redraw,
|
||||
destroy: () => {
|
||||
listeners.forEach((removeListener) => removeListener());
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
mount.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { putToVm } from './upload/put_to_vm.js';
|
||||
export { uploadViaVM } from './upload/upload_via_vm.js';
|
||||
export { DEFAULTS };
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
|
||||
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
|
||||
* Корневой узел файла, папки или ZIP-архива.
|
||||
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
|
||||
*
|
||||
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
|
||||
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
|
||||
* же путём, но другим размером.
|
||||
*/
|
||||
export function addFileWithDedup(state, fileNode) {
|
||||
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
|
||||
const accept = (node) => {
|
||||
if (node.kind === 'file') {
|
||||
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
|
||||
const key = `${node.path}\u0000${node.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node;
|
||||
}
|
||||
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
|
||||
node.children = node.children.map(accept).filter(Boolean);
|
||||
return node.children.length ? node : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||
if (accepted.kind !== 'file') {
|
||||
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
|
||||
if (existing) {
|
||||
mergeChildren(existing, accepted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeChildren(target, incoming) {
|
||||
incoming.children.forEach((child) => {
|
||||
if (child.kind === 'file') {
|
||||
const duplicate = target.children.some((existing) => existing.kind === 'file'
|
||||
&& existing.path === child.path && existing.file.size === child.file.size);
|
||||
if (!duplicate) target.children.push(child);
|
||||
return;
|
||||
}
|
||||
const existing = target.children.find((candidate) => candidate.kind !== 'file'
|
||||
&& candidate.path === child.path);
|
||||
if (existing) mergeChildren(existing, child);
|
||||
else target.children.push(child);
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||
*
|
||||
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||
*
|
||||
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||
*/
|
||||
export function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||
*
|
||||
* @param {number} bytes Размер в байтах.
|
||||
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
|
||||
*/
|
||||
export function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B`
|
||||
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||
: `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
/**
|
||||
* Обрабатывает список файлов из обычного file input.
|
||||
*
|
||||
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
|
||||
* @param {FileList|File[]} files Выбранные browser File objects.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
|
||||
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
|
||||
*
|
||||
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
|
||||
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
|
||||
*/
|
||||
export async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
// Обычный файл получает имя как путь: у file input нет относительного пути.
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт обработчик change для обычного выбора файлов.
|
||||
*
|
||||
* @param {object} state Состояние picker, включая флаг busy.
|
||||
* @param {object} cfg Конфигурация picker.
|
||||
* @param {object} elements DOM-элементы picker.
|
||||
* @returns {Function} Async-обработчик для addEventListener('change', ...).
|
||||
*
|
||||
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
|
||||
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
|
||||
*/
|
||||
export function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
|
||||
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
|
||||
* @returns {Function} Async-обработчик события change.
|
||||
*
|
||||
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
|
||||
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
|
||||
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
|
||||
* что и ZIP из обычного file input.
|
||||
*/
|
||||
export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
const roots = new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false });
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||
const addToFolder = (node) => {
|
||||
let current = root;
|
||||
const nodeParts = node.path.split('/').slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: false };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
||||
}
|
||||
}
|
||||
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Добавляет префикс корневой папки к каждому пути дерева.
|
||||
*
|
||||
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
|
||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||
* @returns {object} Тот же узел после изменения путей.
|
||||
*
|
||||
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||
* хранится отдельно в узле дерева.
|
||||
*/
|
||||
export function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
const DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'] },
|
||||
};
|
||||
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...(cfg.labels || {}),
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...(cfg.labels?.columns || {}),
|
||||
},
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||||
*
|
||||
* @param {{kind: string, id: string, name: string, path: string, file?: File,
|
||||
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
|
||||
* @param {number} depth Глубина узла; используется для визуального отступа.
|
||||
* @returns {string} HTML одной строки или поддерева строк.
|
||||
*
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function renderNode(node, depth, cfg) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
const action = isGroup
|
||||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
|
||||
};
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
// Дочерние строки добавляются только для раскрытой группы.
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||||
*
|
||||
* @param {{nodes: Array}} state Состояние дерева.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||||
* @returns {void}
|
||||
*
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length
|
||||
? normalized.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
|
||||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||
const visit = (node) => {
|
||||
if (node.kind === 'file') files.push(node);
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||||
*
|
||||
* @param {Array} nodes Массив узлов текущего уровня.
|
||||
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||||
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||||
*/
|
||||
export function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { node, nodes };
|
||||
const found = findNode(node.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||||
*
|
||||
* @param {Array} nodes Узлы любого уровня.
|
||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
*/
|
||||
export function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
else flattenFiles(node.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
|
||||
*
|
||||
* @param {Blob|File} file Файл для отправки.
|
||||
* @param {string} url Полный целевой URL в буфере.
|
||||
* @param {Object} options Опции отправки:
|
||||
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
|
||||
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
|
||||
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
|
||||
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
|
||||
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
|
||||
*/
|
||||
export function putToVm(file, url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (options.onXHR) {
|
||||
options.onXHR(xhr);
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
xhr.open('PUT', url);
|
||||
xhr.timeout = options.timeoutMs || 300000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && options.onProgress) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
options.onProgress(pct, e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new Error('Сетевая ошибка при отправке в буфер'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
|
||||
*
|
||||
* Архитектура потокового транзита:
|
||||
* Для каждого файла k:
|
||||
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
|
||||
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
|
||||
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
|
||||
* 4. Файл удалён из буфера ВМ, память чиста.
|
||||
* 5. Браузер переходит к файлу k+1.
|
||||
*/
|
||||
|
||||
import { putToVm } from './put_to_vm.js';
|
||||
import { fs } from '../table/fs.js';
|
||||
|
||||
function generateUuid() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
|
||||
*/
|
||||
function normalizeFileEntry(item) {
|
||||
if (item instanceof File || item instanceof Blob) {
|
||||
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
|
||||
}
|
||||
if (item && item.file) {
|
||||
return {
|
||||
name: item.name || item.file.name || 'unnamed',
|
||||
size: item.size ?? item.file.size ?? 0,
|
||||
file: item.file,
|
||||
path: item.path || item.name || '',
|
||||
};
|
||||
}
|
||||
throw new Error('Некорректный объект файла');
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
|
||||
*
|
||||
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
|
||||
* @param {Object} options Параметры загрузки:
|
||||
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
|
||||
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
|
||||
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
|
||||
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
|
||||
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
|
||||
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
|
||||
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
|
||||
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
|
||||
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
|
||||
*/
|
||||
export async function uploadViaVM(files, options = {}) {
|
||||
if (!options.vmUploadUrl) {
|
||||
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
|
||||
}
|
||||
|
||||
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
|
||||
const total = normalizedFiles.length;
|
||||
if (total === 0) {
|
||||
return { ok: true, session: options.session || '', count: 0 };
|
||||
}
|
||||
|
||||
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
|
||||
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
|
||||
let currentSession = options.session || '';
|
||||
let totalAdded = 0;
|
||||
|
||||
for (let k = 0; k < total; k++) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
|
||||
const { name, size, file } = normalizedFiles[k];
|
||||
const fileKey = `${generateUuid()}_${k}`;
|
||||
const fileUrl = `${vmBase}${fileKey}`;
|
||||
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '⏳ В буфер...');
|
||||
}
|
||||
|
||||
// Этап 1: Отправка одного файла в буфер
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await putToVm(file, fileUrl, {
|
||||
signal: options.signal,
|
||||
onXHR: options.onXHR,
|
||||
onProgress(pct, loaded, fileTotal) {
|
||||
const elapsed = (performance.now() - t0) / 1000;
|
||||
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✗ Ошибка буфера');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession,
|
||||
};
|
||||
}
|
||||
|
||||
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '⚡ В сессию...');
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session: currentSession,
|
||||
files: [{ name, size, url: fileUrl }],
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Бэкенд вернул ошибку');
|
||||
}
|
||||
|
||||
currentSession = data.session || currentSession;
|
||||
totalAdded = data.count ?? (totalAdded + 1);
|
||||
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✓ Доставлен');
|
||||
}
|
||||
if (options.onFileComplete) {
|
||||
options.onFileComplete({
|
||||
fileIndex: k,
|
||||
name,
|
||||
size,
|
||||
session: currentSession,
|
||||
totalAdded,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✗ Ошибка приёма');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
|
||||
}
|
||||
|
||||
return { ok: true, session: currentSession, count: totalAdded };
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||||
import { unzipSync } from 'fflate';
|
||||
import { rebaseTree } from '../table/rebase_tree.js';
|
||||
|
||||
const DEFAULT_LIMITS = {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
};
|
||||
|
||||
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name.split('/').at(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует путь ZIP entry до его использования в дереве.
|
||||
*
|
||||
* @param {string} entryName Сырой путь из центрального каталога ZIP.
|
||||
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
|
||||
*
|
||||
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
|
||||
* Это предотвращает traversal и появление ложных групп в UI.
|
||||
*/
|
||||
function safeEntryParts(entryName) {
|
||||
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
|
||||
const parts = entryName.split('/');
|
||||
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
|
||||
}
|
||||
|
||||
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
||||
function addPath(root, parts, fileNode) {
|
||||
let current = root;
|
||||
parts.forEach((part, index) => {
|
||||
const last = index === parts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last
|
||||
? fileNode
|
||||
: node('folder', part, `${current.path}/${part}`);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
|
||||
*
|
||||
* @param {Uint8Array} data Распаковываемые байты ZIP.
|
||||
* @param {string} zipName Имя текущего архива для корневого узла и пути.
|
||||
* @param {string[]} allowedExt Разрешённые расширения документов.
|
||||
* @param {number} depth Текущая глубина вложенных ZIP.
|
||||
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
|
||||
* @throws {Error} При превышении глубины или повреждённом ZIP.
|
||||
*/
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
|
||||
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
|
||||
const entries = unzipSync(data, {
|
||||
filter: (entry) => {
|
||||
budget.entries += 1;
|
||||
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
|
||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const root = node('zip', zipName, zipName);
|
||||
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
|
||||
if (entryName.endsWith('/')) continue;
|
||||
const parts = safeEntryParts(entryName);
|
||||
if (!parts) continue;
|
||||
const normalizedName = parts.join('/');
|
||||
if (normalizedName.toLowerCase().endsWith('.zip')) {
|
||||
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
|
||||
if (nested) {
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = parts.at(-1);
|
||||
addPath(root, parts, nested);
|
||||
}
|
||||
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
||||
// Запрещённые документы отбрасываются до создания browser File.
|
||||
const path = `${zipName}/${normalizedName}`;
|
||||
const file = makeFile(entryData, path);
|
||||
addPath(root, parts, node('file', parts.at(-1), path, [], file));
|
||||
}
|
||||
}
|
||||
return root.children.length ? root : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
|
||||
*
|
||||
* @param {File} file Browser File с ZIP-содержимым.
|
||||
* @param {string[]} allowedExt Разрешённые расширения.
|
||||
* @param {object} customLimits Ограничения распаковки.
|
||||
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
|
||||
*/
|
||||
export async function listZipFiles(file, allowedExt, customLimits = {}) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const limits = { ...DEFAULT_LIMITS, ...customLimits };
|
||||
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
|
||||
}
|
||||
Reference in New Issue
Block a user