Compare commits
142
Commits
v1-monolith
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301ceecaa8 | ||
|
|
cb6274a887 | ||
|
|
87a6df45c5 | ||
|
|
c102d54887 | ||
|
|
a182a2f38a | ||
|
|
5f0ca8e463 | ||
|
|
8484197eec | ||
|
|
6e8f79ac56 | ||
|
|
2aea7bf2f6 | ||
|
|
0ff904ceb6 | ||
|
|
fd4f152fc8 | ||
|
|
d205118cd5 | ||
|
|
4af247938f | ||
|
|
0640e7d47f | ||
|
|
5efed1c577 | ||
|
|
40372fe03f | ||
|
|
e66dff23b8 | ||
|
|
68fada075e | ||
|
|
338cb2f372 | ||
|
|
9b4060d8c5 | ||
|
|
8158a8bc01 | ||
|
|
26572abb3b | ||
|
|
cd29e727f3 | ||
|
|
e513bf3c9e | ||
|
|
17bc4aebf8 | ||
|
|
b3298b415e | ||
|
|
5e7319cd42 | ||
|
|
204e865aff | ||
|
|
6fe1ba2b42 | ||
|
|
0ceaee1089 | ||
|
|
b6dac6f2bd | ||
|
|
79cd25a762 | ||
|
|
bbffb00eb8 | ||
|
|
ddbf67924b | ||
|
|
00d54dbfb6 | ||
|
|
444ef4b732 | ||
|
|
f072fc9fdb | ||
|
|
f38ba4ca37 | ||
|
|
82b5cb4402 | ||
|
|
ec9107cff4 | ||
|
|
fe45b07c4f | ||
|
|
cd22fd62a6 | ||
|
|
5e4418f648 | ||
|
|
2949d0ef09 | ||
|
|
1ad35dada7 | ||
|
|
ae09c23d81 | ||
|
|
d0f12ef4a3 | ||
|
|
a34a78d8b9 | ||
|
|
ab08acd60d | ||
|
|
f005e8cf29 | ||
|
|
1da496c2e5 | ||
|
|
17fc2670a5 | ||
|
|
7ed1f5d650 | ||
|
|
3df63df0b0 | ||
|
|
75568a2586 | ||
|
|
9ea3c2991a | ||
|
|
cbf7de27d8 | ||
|
|
d94fa3f556 | ||
|
|
7d39885d90 | ||
|
|
e7da82538f | ||
|
|
a91a5e9538 | ||
|
|
c25b22cfe6 | ||
|
|
68571fbbcd | ||
|
|
eaef850421 | ||
|
|
0950b68649 | ||
|
|
322626a372 | ||
|
|
f9b88cbf93 | ||
|
|
a56a819329 | ||
|
|
f3c854c5fb | ||
|
|
23b4ad9889 | ||
|
|
29e2d08cdb | ||
|
|
0cdef67c1f | ||
|
|
8c59c4b7e0 | ||
|
|
70226d8c39 | ||
|
|
4a4de0f8e2 | ||
|
|
38109ba6ec | ||
|
|
adab2c624f | ||
|
|
a380c0e70c | ||
|
|
bbc45e155f | ||
|
|
7bdcb5b7b4 | ||
|
|
c878b3db1a | ||
|
|
bf202cdfa4 | ||
|
|
e3405556b5 | ||
|
|
46d8ced0dd | ||
|
|
e4975fb37e | ||
|
|
613b645456 | ||
|
|
b7075dd8bf | ||
|
|
75c7424963 | ||
|
|
0d16bb6520 | ||
|
|
491b49f633 | ||
|
|
76a2d4b7ed | ||
|
|
c35e8da357 | ||
|
|
345448caa4 | ||
|
|
18922b486b | ||
|
|
e590d8ab9a | ||
|
|
cbf619aaba | ||
|
|
0998892d7f | ||
|
|
d0e874d87e | ||
|
|
81bdee6203 | ||
|
|
efaa42dd2e | ||
|
|
9145345882 | ||
|
|
ee42865fcd | ||
|
|
3ba201748c | ||
|
|
f98b4ba7a5 | ||
|
|
a1574677a5 | ||
|
|
1a84770e82 | ||
|
|
75b8476703 | ||
|
|
370a869aef | ||
|
|
62c7ea0162 | ||
|
|
df15f48ecc | ||
|
|
adf3002e3d | ||
|
|
59f7601266 | ||
|
|
f15af2866d | ||
|
|
1d91d5f92e | ||
|
|
fd5e1613a5 | ||
|
|
303830528f | ||
|
|
3d55562f02 | ||
|
|
dacf0d584f | ||
|
|
5c426cb037 | ||
|
|
0c2b636d31 | ||
|
|
a87361c159 | ||
|
|
7fc41aabce | ||
|
|
2731bc7f09 | ||
|
|
724124b3fc | ||
|
|
72ae915935 | ||
|
|
3e5aef70a0 | ||
|
|
34f9a6f74f | ||
|
|
84ae7439f5 | ||
|
|
2a2cc1467c | ||
|
|
f5427b0db7 | ||
|
|
af2f2a9f71 | ||
|
|
2ceeb05a8b | ||
|
|
579f9c8334 | ||
|
|
d607d7d306 | ||
|
|
4c3f9d4e49 | ||
|
|
cc2f627de2 | ||
|
|
0707d53b37 | ||
|
|
7a005f7b06 | ||
|
|
51bc1a79e7 | ||
|
|
2cf4e08100 | ||
|
|
37581eb601 | ||
|
|
a2f754ab4f |
@@ -0,0 +1,24 @@
|
||||
# ⛔ ЖЁСТКИЕ ПРАВИЛА
|
||||
- НЕЛЬЗЯ менять код без «делай» — показать план → ждать
|
||||
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
|
||||
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
|
||||
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
|
||||
- НЕЛЬЗЯ предполагать — сомневаешься = спроси
|
||||
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
|
||||
|
||||
# ✅ ПОРЯДОК ПРАВКИ
|
||||
1. План → «делай»
|
||||
2. Правим → проверяем syntax → проверяем import
|
||||
3. git add -A && git commit -m "..." && git push
|
||||
4. Bump VERSION в site/app.py перед push
|
||||
|
||||
# 📋 ДОКИ
|
||||
- Порядок правок: docs/DEVELOPMENT.md
|
||||
- Архитектура: docs/ARCHITECTURE.md
|
||||
- Деплой: docs/DEPLOY.md
|
||||
- История: History/
|
||||
|
||||
# 📋 ПРОЕКТ
|
||||
Штурвал Managed Flask, без Dockerfile/gunicorn, без БД
|
||||
URL: https://drhider.pythonk8s.dev.nubes.ru/
|
||||
ВМ legacy: ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
|
||||
@@ -0,0 +1,4 @@
|
||||
# ⛔⛔⛔ STOP: без «делай» ничего не делать
|
||||
# ⛔⛔⛔ ВОПРОС (?, почему, как, где, что) — ТОЛЬКО ОТВЕТ. Читать/узнавать МОЖНО, менять НЕЛЬЗЯ.
|
||||
# ⛔ Не спешить. Не лезть в contracts/. Не гадать — спросить.
|
||||
# 📋 Правила: .github/CODERULES.md
|
||||
@@ -1,3 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
# TMP/
|
||||
# about1500.md
|
||||
*.har
|
||||
chrome-net-export-log.json
|
||||
TSTFILES/
|
||||
|
||||
+5
-2
@@ -1,8 +1,11 @@
|
||||
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 drhider.py drhider_server.py /app/
|
||||
COPY site /app/site
|
||||
COPY drhider /app/drhider
|
||||
EXPOSE 5000
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "300", "--workers", "1", "--worker-class", "gevent", "--limit-request-field_size", "0", "--limit-request-body", "0", "site.app:app"]
|
||||
CMD ["python", "site/app.py"]
|
||||
|
||||
@@ -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,32 @@
|
||||
# Запрос к Соннету — ERR_TIMED_OUT при открытии нового окна (2026-07-12)
|
||||
|
||||
## Симптом
|
||||
|
||||
DrHider на Managed Flask (Штурвал/pythonk8s). При открытии страницы в НОВОМ окне браузера — ERR_TIMED_OUT (20+ секунд). Старые вкладки работают нормально.
|
||||
|
||||
## Факты
|
||||
|
||||
- Изнутри кластера (kubectl exec → curl): HTTP 200 за 2.1 секунды
|
||||
- Снаружи (браузер, curl с локальной машины): таймаут 10+ секунд
|
||||
- v1 работало с gunicorn (CMD: `gunicorn --bind :5000 --worker-class gevent`)
|
||||
- Сейчас: `app.run(host="0.0.0.0", port=5000)` — Flask dev server (Werkzeug)
|
||||
- Страница: 12 KB HTML, 2 статических файла (logo.svg, favicon.svg — оба 2.8 KB)
|
||||
- Ноль внешних зависимостей (никаких CDN, шрифтов, JS-библиотек)
|
||||
- HTTP-заголовки: Cache-Control: no-cache, no-store, must-revalidate
|
||||
- Штурвал запускает: cd site && python app.py
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Может ли Flask dev server (Werkzeug) быть причиной ERR_TIMED_OUT на новых соединениях, в то время как gunicorn работал нормально?
|
||||
2. Если да — какой механизм? (keep-alive, thread pool, socket backlog?)
|
||||
3. Если нет — что ещё может вызывать таймаут именно на НОВЫХ окнах при работающих старых?
|
||||
4. Нужно ли вернуть gunicorn или есть другой способ заставить app.run() работать стабильно?
|
||||
5. Может ли `Cache-Control: no-store` заставлять браузер делать лишние запросы при открытии нового окна?
|
||||
|
||||
## Контекст платформы
|
||||
|
||||
Штурвал — Managed Flask на Kubernetes:
|
||||
- Запускает: `cd /var/www && pip install -r requirements.txt && cd site && python app.py`
|
||||
- Readiness probe: `tcp-socket :5000`
|
||||
- Нет Dockerfile, нет nginx перед приложением
|
||||
- Ingress: внешний LoadBalancer → ClusterIP сервис → под
|
||||
@@ -0,0 +1,83 @@
|
||||
# Запрос к Соннету — анализ результатов DrHider v3 (2026-07-12)
|
||||
|
||||
## Контекст
|
||||
|
||||
DrHider v3 — обфускация документов через LLM + Markdown. Результаты уже есть.
|
||||
Покажи Соннету реальный mapping.csv и фрагмент обфусцированного .md.
|
||||
|
||||
## Промпт для Соннета
|
||||
|
||||
Вот текущий LLM-промпт в scanner.py:
|
||||
|
||||
```
|
||||
You are a PII detector. Find ALL sensitive or private information in the text below.
|
||||
|
||||
Return a JSON array. Each item must have:
|
||||
- "type": short snake_case label describing the entity
|
||||
(e.g. person_name, phone, email, address, inn, company, passport,
|
||||
contract_number, employee_id, bank_account — WHATEVER you see)
|
||||
- "value": the EXACT string from the text (copy verbatim)
|
||||
|
||||
Rules:
|
||||
- Copy value VERBATIM — same spaces, punctuation, case as in text
|
||||
- If same value appears multiple times — include only once
|
||||
- Invent the type label yourself based on what you see
|
||||
- Return ONLY the JSON array, no other text, no markdown wrapping
|
||||
```
|
||||
|
||||
Результат mapping.csv (первые 10 строк):
|
||||
```csv
|
||||
тип_данных,оригинал,замена
|
||||
text,"""****XXX****001****""","""****IPD****084****"""
|
||||
text,"""НУБЕС""","""ФИОЩЦ"""
|
||||
phone,+7 (495) 789-41-35,+7 (343) 986-75-93
|
||||
email,info@nubes.ru,bxkuno@company.local
|
||||
company,"ЗАО ""****XXX****001****""",ООО «Спектр»
|
||||
company,"ЗАО ""XXX001""",ЗАО «Меридиан»
|
||||
inn_ul,ИНН 9706005293,ИНН 9325731296
|
||||
text,Исполнителем Заказчику Сторонами,Морозов М.П.
|
||||
kpp,КПП 772401001,КПП 051543039
|
||||
```
|
||||
|
||||
Фрагмент обфусцированного .md:
|
||||
```markdown
|
||||
**ООО «Спектр»** именуемое в дальнейшем "Заказчик", в лице Генерального директора ФИО,
|
||||
действующего на основании Устава, и **ООО **"**НУБЕС**"**, именуемое в дальнейшем
|
||||
"Исполнитель", в лице Генерального директора Степанов Н.М.
|
||||
```
|
||||
|
||||
## Проблемы
|
||||
|
||||
1. **Контекстная роль Исполнителя** — сервис должен быть УНИВЕРСАЛЬНЫМ, без хардкода конкретных названий. LLM должна из контекста понимать: «вот эта компания — Исполнитель (сторона договора, оказывающая услуги), её НЕ заменяем». Как научить LLM этому без указания конкретных имён?
|
||||
2. **Тип "text" для многих сущностей** — LLM не всегда правильно определяет тип (например, "Исполнителем Заказчику Сторонами" — это не ПДн, а просто текст договора)
|
||||
3. **Regex находит структурированное (ИНН, телефон), LLM — остальное** — есть дублирование
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Как научить LLM определять Исполнителя из контекста договора (без хардкода имён) и НЕ заменять его данные?
|
||||
2. Как заставить LLM точнее определять тип сущности (не "text" для всего подряд)?
|
||||
3. Нужно ли добавить в промпт примеры того что НЕ является ПДн (юридические термины, роли сторон)?
|
||||
4. Стоит ли передавать LLM уже найденное regex'ом чтобы избежать дублирования?
|
||||
5. Правильно ли что промпт на английском, а документы на русском?
|
||||
|
||||
## Текущий промпт (полностью)
|
||||
|
||||
```
|
||||
You are a PII (Personally Identifiable Information) detector.
|
||||
Find ALL sensitive or private information in the text below.
|
||||
|
||||
Return a JSON array. Each item must have:
|
||||
- "type": short snake_case label describing the entity
|
||||
(e.g. person_name, phone, email, address, inn, company, passport,
|
||||
contract_number, employee_id, bank_account — WHATEVER you see)
|
||||
- "value": the EXACT string from the text (copy verbatim)
|
||||
|
||||
Rules:
|
||||
- Copy value VERBATIM — same spaces, punctuation, case as in text
|
||||
- If same value appears multiple times — include only once
|
||||
- Invent the type label yourself based on what you see
|
||||
- Return ONLY the JSON array, no other text, no markdown wrapping
|
||||
|
||||
Text:
|
||||
{combined[:8000]}
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
# Запрос к Соннету — нумерация вместо генерации фейков (2026-07-12)
|
||||
|
||||
## Контекст
|
||||
|
||||
DrHider обфусцирует документы: ищет ПДн → заменяет на фиктивные значения.
|
||||
Сейчас используется генерация "похожих" фейков (Иванов И.И., ООО «Ромашка», +7(495)...).
|
||||
|
||||
## Проблема
|
||||
|
||||
1. **Адреса → абракадабра.** Ул. Стекольная → иг. 8ц Лжчюсвхшбр. Потому что fallback-генератор меняет кириллицу на случайную кириллицу.
|
||||
2. **Сложность.** 11 генераторов под каждый тип данных. Каждый надо поддерживать.
|
||||
3. **Ложное ощущение реальности.** Фейковые ИНН с правильной контрольной суммой выглядят как настоящие.
|
||||
|
||||
## Идея
|
||||
|
||||
Заменить всю генерацию на **нумерацию по типам:**
|
||||
|
||||
| Оригинал | Замена |
|
||||
|---|---|
|
||||
| Иванов И.И. | `Лицо_0042` |
|
||||
| ООО «НУБЕС» | `Компания_0003` |
|
||||
| ул. Стекольная, д.7 | `Адрес_0015` |
|
||||
| +7 (495) 789-41-35 | `Телефон_0007` |
|
||||
| info@nubes.ru | `Email_0004` |
|
||||
| ИНН 9706005293 | `ИНН_0002` |
|
||||
| Р/с 40702... | `Счёт_0001` |
|
||||
| договор-XXX001.docx | `Файл_0006` |
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. **Плюсы/минусы** нумерации vs генерации фейков для задачи обфускации документов?
|
||||
2. **Как нумеровать?** Глобальный счётчик на каждый тип? Или в рамках одного документа?
|
||||
3. **Стоит ли сохранять формат?** Например `Телефон_0007` vs `+7 (000) 000-00-07`?
|
||||
4. **mapping.csv** — при нумерации он становится главным документом аудита. Это ок?
|
||||
5. **UUID вместо номеров?** `Лицо_a3f4b2` — лучше или хуже последовательных номеров?
|
||||
|
||||
## Текущий mapping.csv (для контекста)
|
||||
|
||||
```csv
|
||||
тип_данных,оригинал,замена
|
||||
phone,+7 (495) 789-41-35,+7 (495) 906-87-80
|
||||
email,info@nubes.ru,ypiorej@company.local
|
||||
company,"ЗАО ""XXX001""",ООО «Альянс»
|
||||
inn_ul,ИНН 9706005293,ИНН 1846691249
|
||||
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
|
||||
text,"115404, г. Москва, ул. 1-я Стекольная, д.7с2","084810, б. Ввжзгд, гф. 6-в Фтпжыобнзщ, я.8с1"
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# Запрос к Соннету — нумерация vs фейки для downstream LLM (2026-07-12)
|
||||
|
||||
## Контекст
|
||||
|
||||
Я делаю сервис **DrHider** — обфускация документов. Находит ПДн → заменяет.
|
||||
После обфускации документы идут на дальнейшую LLM-обработку — **сверка спецификаций**
|
||||
(другой сервис contracts, извлекает типы/номера/даты/контрагентов через LLM).
|
||||
|
||||
Сейчас генерация фейков: `Иванов И.И.` → `Петров А.С.`, `ООО «Ромашка»` → `ООО «Спектр»`.
|
||||
|
||||
**Проблема:** для неизвестных типов (адреса, нестандартные ID) — character-class замена
|
||||
даёт абракадабру: `ул. Стекольная` → `иг. 8ц Лжчюсвхшбр`. Downstream LLM не может это обработать.
|
||||
|
||||
## Варианты замены
|
||||
|
||||
### А. Чистая нумерация
|
||||
`Иванов И.И.` → `Лицо_0042`, `ООО «Ромашка»` → `Компания_0003`, `ул. Ленина` → `Адрес_0015`
|
||||
|
||||
Плюс: никакой абракадабры. Минус: LLM теряет контекст («Лицо_0042 заключил договор» — не понимает семантику).
|
||||
|
||||
### Б. Фейки (сейчас)
|
||||
`Иванов И.И.` → `Петров А.С.`, `ООО «Ромашка»` → `ООО «Спектр»`
|
||||
|
||||
Плюс: LLM понимает роли. Минус: сложно (11 генераторов), ложное ощущение реальности,
|
||||
для неизвестных типов — абракадабра.
|
||||
|
||||
### В. Гибрид: шаблон + номер
|
||||
`Иванов И.И.` → `Иванов_5677`, `ООО «Ромашка»` → `ООО_Технология_0034`,
|
||||
`ул. Стекольная, д.7` → `ул_Ленина_0015`, `+7 (495) 789-41-35` → `+7_495_000_0042`,
|
||||
`info@nubes.ru` → `email_0007@fake`
|
||||
|
||||
Плюс: LLM видит что это человек/компания/адрес, номер гарантирует уникальность и обфускацию.
|
||||
Минус: всё равно нужны шаблоны для каждого типа.
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Какой вариант лучше для downstream LLM-обработки (извлечение контрагентов, дат, номеров)?
|
||||
2. Вариант В — достаточно ли LLM поймёт что `Иванов_5677` это персональные данные?
|
||||
3. Для неизвестных типов — `[Данные_NNNN]` как fallback, ок?
|
||||
4. Глобальная нумерация (сквозная через все документы пакета) vs локальная (в рамках одного файла)?
|
||||
5. Стоит ли сохранять частичную структуру (например телефон `+7_495_000_0042` сохраняет формат номера)?
|
||||
|
||||
## Downstream задача (для контекста)
|
||||
|
||||
LLM в сервисе contracts получает текст документа и должна извлечь:
|
||||
- Контрагент («с кем договор»)
|
||||
- Номер и дату договора
|
||||
- Список услуг с ценами (таблица)
|
||||
|
||||
Промпт для contracts LLM (фрагмент):
|
||||
```
|
||||
Извлеки из текста: тип документа, номер, дату, контрагента.
|
||||
Верни JSON: {"type":"договор","number":"...","date":"...","counterparty":"..."}
|
||||
```
|
||||
@@ -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,19 @@
|
||||
# Ответ Соннета — ERR_TIMED_OUT (2026-07-12)
|
||||
|
||||
## Причина
|
||||
|
||||
Werkzeug dev server: **один поток на TCP-соединение**. Браузер держит 6 keep-alive соединений на вкладку. 6 потоков заняты idle-соединениями → backlog (5) переполнен → новое окно получает SYN-дроп → таймаут.
|
||||
|
||||
gunicorn+gevent: async I/O, idle-соединения не занимают ресурсы.
|
||||
|
||||
## Решение
|
||||
|
||||
Три варианта:
|
||||
|
||||
| Вариант | Что | Изменения |
|
||||
|---|---|---|
|
||||
| A (waitress) | Pure Python WSGI | `requirements.txt` + 2 строки в `app.py` |
|
||||
| B (gevent) | Async, как gunicorn | `requirements.txt` + 2 строки в `app.py` |
|
||||
| C (gunicorn) | Точно как v1 | `requirements.txt` + смена CMD в Штурвале |
|
||||
|
||||
Соннет рекомендует: **A** если Штурвал не меняет CMD, **C** если меняет.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Ответ Соннета v2 — улучшение LLM-промпта (2026-07-12)
|
||||
|
||||
## Q1. Как научить LLM определять Исполнителя из контекста?
|
||||
|
||||
**Двухфазная инструкция в промпте:**
|
||||
- STEP 1: определи кто «Исполнитель» — его данные НЕ трогать
|
||||
- STEP 2: найди всё остальное ПДн
|
||||
|
||||
Убирает хардкод `НУБЕС` из кода. Работает на любом договоре.
|
||||
|
||||
## Q2. Точная классификация типов
|
||||
|
||||
Вместо `"Invent the type label yourself"` → **фиксированный список:**
|
||||
`person_name, company, phone, email, address, inn, kpp, ogrn, bik, passport, contract_number, bank_account, other_id`
|
||||
|
||||
Если сомневаешься — НЕ включай.
|
||||
|
||||
## Q3. Примеры что НЕ является ПДн
|
||||
|
||||
Обязательно добавить блок:
|
||||
```
|
||||
NOT PII:
|
||||
- Юридические роли: Исполнитель, Заказчик, Стороны
|
||||
- Термины договора: Услуги, Приложение, Договор, НДС
|
||||
- Должности: Генеральный директор
|
||||
- Сама компания-Исполнитель
|
||||
```
|
||||
|
||||
## Q4. Передавать LLM уже найденное regex'ом
|
||||
|
||||
`scan_llm_ner` получает `mapping.keys()` → промпт: "Already captured (skip these): ..."
|
||||
Исключает дублирование, экономит токены.
|
||||
|
||||
## Q5. Язык промпта
|
||||
|
||||
Гибрид: структурные инструкции на английском, NOT PII и роли — на русском.
|
||||
|
||||
---
|
||||
|
||||
## План реализации (scanner.py)
|
||||
|
||||
1. Убрать хардкод `НУБЕС` из `scan_regex`
|
||||
2. `scan_llm_ner` — передавать `mapping.keys()` как "Already captured"
|
||||
3. Новый промпт:
|
||||
- Фиксированный список типов
|
||||
- Блок NOT PII (русский)
|
||||
- Двухфазная инструкция (STEP 1: Исполнитель, STEP 2: остальное)
|
||||
@@ -0,0 +1,46 @@
|
||||
# Ответ Соннета v3 — нумерация вместо генерации фейков (2026-07-12)
|
||||
|
||||
## Q1. Нумерация vs фейки
|
||||
- ✅ Адреса без абракадабры, код проще, аудит прозрачнее
|
||||
- ❌ Документ нечитаем (`Лицо_0042 заключил договор с Компания_0003`)
|
||||
- Вывод: для аудита/хранения — отлично, для показа людям — фейки лучше
|
||||
|
||||
## Q2. Глобальный счётчик на весь пакет
|
||||
Одна сущность в разных документах → один номер. `TwoPassObfuscator` хранит counters.
|
||||
|
||||
## Q3. Сохранять формат?
|
||||
Рекомендация: `Тип_NNNN` — проще всего, аудит максимально прозрачен.
|
||||
|
||||
## Q4. mapping.csv — главный документ аудита
|
||||
Да, это улучшение. mapping.csv — секретный ключ деобфускации.
|
||||
|
||||
## Q5. Номера vs UUID
|
||||
Номера (`Лицо_0042`) — лучше для читаемости и аудита.
|
||||
|
||||
---
|
||||
|
||||
## План изменений
|
||||
|
||||
### Удалить
|
||||
- `drhider/generators/` — все 12 файлов
|
||||
- `drhider/checksum.py` — больше не нужен (ИНН без контрольных сумм)
|
||||
- `drhider/random_utils.py` — больше не нужен
|
||||
|
||||
### Изменить
|
||||
- `scanner.py`: добавить `TYPE_PREFIXES` + `_next_token()`, убрать импорты генераторов
|
||||
- `obfuscator.py`: `TwoPassObfuscator` хранит `counters` dict
|
||||
- `config.py`: убрать словари имён/городов (RU_SURNAMES, ...)
|
||||
|
||||
### Новая логика замены
|
||||
```python
|
||||
TYPE_PREFIXES = {
|
||||
"person_name": "Лицо", "company": "Компания", "phone": "Телефон",
|
||||
"email": "Email", "address": "Адрес", "inn": "ИНН", ...
|
||||
}
|
||||
_FALLBACK = "Данные"
|
||||
|
||||
def _next_token(entity_type, counters):
|
||||
prefix = TYPE_PREFIXES.get(entity_type, _FALLBACK)
|
||||
counters[prefix] = counters.get(prefix, 0) + 1
|
||||
return f"{prefix}_{counters[prefix]:04d}"
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Ответ Соннета v4 — гибридный формат замен (2026-07-12)
|
||||
|
||||
## Итог: Вариант В (шаблон + номер)
|
||||
|
||||
| Тип | Формат замены | Пример |
|
||||
|---|---|---|
|
||||
| ФИО | `Фамилия_NNNN` | `Иванов_5677` |
|
||||
| Компания | `ООО_Слово_NNNN` | `ООО_Технология_0034` |
|
||||
| Адрес | `ул_Шаблон_NNNN` | `ул_Ленина_0015` |
|
||||
| Телефон | `+7_код_000_NNNN` | `+7_495_000_0042` |
|
||||
| Email | `email_NNNN@fake` | `email_0007@fake` |
|
||||
| ИНН | `ИНН_NNNNNNNNNN` | `ИНН_0000000042` |
|
||||
| Неизвестный тип | `[Тип_NNNN]` | `[Адрес_0023]` |
|
||||
|
||||
## Ключевые принципы
|
||||
1. **Читаемо человеком** — не ужиматься, пусть будет естественно
|
||||
2. **Однозначно для LLM** — префикс указывает тип сущности
|
||||
3. **Глобальная нумерация** — одна сущность = один номер во всех файлах пакета
|
||||
4. **Цены/суммы** — не обфусцировать (нужны для downstream сверки)
|
||||
@@ -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,45 @@
|
||||
# v3.0.0 — универсальная архитектура (2026-07-12)
|
||||
|
||||
Переход на Markdown-пайплайн + универсальное LLM-обнаружение.
|
||||
|
||||
## Что изменилось
|
||||
|
||||
### 1. `generators/fallback.py` — новый
|
||||
Character-class preserving замена для неизвестных типов сущностей.
|
||||
Цифры→цифры, буквы→буквы, спецсимволы→без изменений.
|
||||
|
||||
### 2. `scanner.py` — универсальный LLM-промпт
|
||||
- Убран жёсткий список типов (person, company, address, passport)
|
||||
- LLM САМА придумывает тип (snake_case) — person_name, contract_number, employee_id...
|
||||
- Если тип не совпадает с известными — fallback-генератор
|
||||
- Промпт на английском (LLM точнее)
|
||||
|
||||
### 3. `extractor.py` — Markdown-пайплайн
|
||||
- `docx_to_markdown()`: DOCX → MD (Heading → #, Bold → **, таблицы → |...|)
|
||||
- `pdf_to_markdown()`: PDF → MD (текст + таблицы, без форматирования)
|
||||
- `extract_text()` возвращает только str (без docx-объектов)
|
||||
- Удалён `convert_pdfs_to_docx()` (PDF → MD напрямую)
|
||||
|
||||
### 4. `replacer.py` — упрощён
|
||||
- Удалён `replace_in_text()` (заменён на `apply_replacements()` + `.encode()`)
|
||||
- Оставлен `replace_in_docx()` для будущего DOCX-выхода
|
||||
|
||||
### 5. `obfuscator.py` — новый пайплайн
|
||||
- Pass 1: extract MD → scan_regex + scan_llm_ner
|
||||
- Pass 2: apply_replacements к MD → .md файлы
|
||||
- Нет docx-объектов, нет convert_pdfs_to_docx
|
||||
|
||||
## Коммиты
|
||||
|
||||
- `579f9c8` — generators: fallback.py
|
||||
- `2ceeb05` — scanner.py: универсальный промпт
|
||||
- `af2f2a9` — extractor.py: MD + obfuscator.py: новый пайплайн
|
||||
- `f5427b0` — replacer.py: удалён replace_in_text
|
||||
- `2a2cc14` — v3.0.0
|
||||
|
||||
## Плюсы
|
||||
|
||||
- LLM точнее (нет XML-шума, только Markdown)
|
||||
- Новые типы сущностей — без правки кода
|
||||
- Код проще: -120 строк, -1 функция, -1 модуль (convert_pdfs_to_docx)
|
||||
- Единый внутренний формат (Markdown)
|
||||
@@ -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,30 @@
|
||||
# План v3.1 — пофайловая обработка + прогресс в таблице (2026-07-12)
|
||||
|
||||
## Проблема
|
||||
Фронтенд v3.0 отправляет все файлы одним запросом → долгий LLM-вызов → таймаут.
|
||||
Нужно: обрабатывать по одному, прогресс в таблице, все результаты → один ZIP.
|
||||
|
||||
## Решение
|
||||
|
||||
### Фронтенд (index.html)
|
||||
1. **XHR** вместо fetch — надёжнее для blob, явный timeout
|
||||
2. **Колонка «Статус»** в таблице файлов
|
||||
3. **По одному файлу:** цикл, каждый файл → отдельный XHR → ZIP
|
||||
4. **Прогресс в таблице:** ⏳ → ✓ 2.1 сек (или ✗ ошибка)
|
||||
5. **JSZip** — склеивает все полученные ZIP'ы в один
|
||||
6. **Скачивание** — один файл `drhider_output.zip` в конце
|
||||
|
||||
### Статусы в таблице
|
||||
| Статус | Отображение |
|
||||
|---|---|
|
||||
| Ожидание | ⏳ |
|
||||
| Успех | ✓ 2.1 сек |
|
||||
| Ошибка | ✗ сообщение |
|
||||
|
||||
### Бэкенд
|
||||
Без изменений. API уже принимает один файл.
|
||||
|
||||
### НЕ трогать
|
||||
- gunicorn не нужен (файлы по одному, каждый запрос ≤10 сек)
|
||||
- app.run() — остаётся
|
||||
- drhider пакет — без изменений
|
||||
@@ -0,0 +1,44 @@
|
||||
# Запрос к Opus: валидация MSS Clamping плана
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Кластер: bare-metal Kubernetes, 4 ноды, Cilium CNI (Geneve-туннель), kube-vip для VIP.
|
||||
Штурвал управляет ingress через Helm-чарт (`shturval-ingress-controller-2.12.1`).
|
||||
|
||||
## Проблема
|
||||
|
||||
При `externalTrafficPolicy: Cluster` большие файлы (>5 MB) вызывают TCP stall на 51 секунду.
|
||||
Диагностика: cross-node Geneve-туннель добавляет overhead (~50 байт) → пакет > MTU 1500 → ICMP Frag Needed блокируется → TCP RTO backoff.
|
||||
|
||||
Текущий костыль: `externalTrafficPolicy: Local` + `kubectl scale replicas=4` (Штурвал сбрасывает на 2 каждые ~час).
|
||||
|
||||
## Предлагаемое решение
|
||||
|
||||
Вместо костыля — выставить MTU в Cilium ConfigMap:
|
||||
|
||||
```yaml
|
||||
# kubectl edit configmap -n kube-system cilium-config
|
||||
mtu: 1450
|
||||
```
|
||||
|
||||
После этого:
|
||||
1. Вернуть `externalTrafficPolicy: Cluster`
|
||||
2. Оставить ingress `replicaCount: 2` (стоковое значение Штурвала)
|
||||
3. Никаких ручных scale
|
||||
|
||||
## Вопросы к Opus
|
||||
|
||||
**Читать только:** `docs/MSS-CLAMPING-PLAN.md`, `docs/KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md`, `docs/INGRESS-SOLUTIONS.md`.
|
||||
|
||||
1. Корректно ли решение? Есть ли подводные камни при смене MTU через Cilium ConfigMap на работающем кластере?
|
||||
2. Нужно ли что-то ещё кроме `mtu: 1450`? (например, перезапуск подов, пересоздание туннелей)
|
||||
3. Как проверить что MSS clamping реально работает после изменения? (конкретные команды)
|
||||
4. Есть ли риск для других сервисов (Keycloak, etc.) при смене MTU?
|
||||
5. Если Cilium ConfigMap недоступен — iptables-вариант (`TCPMSS --clamp-mss-to-pmtu`) равноценен? На какую ноду вешать правило?
|
||||
6. Итоговый вердикт: это правильный путь, или есть более грамотный вариант?
|
||||
|
||||
**Не лезть:** в код приложения, в TMP, в History (кроме указанных трёх файлов).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Opus: проверка PROBLEM-AND-SOLUTION.md на ошибки
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Найденные ошибки
|
||||
|
||||
### 1. TCP backoff — неправильные числа
|
||||
|
||||
**Было:** `1с → 3с → 7с → 15с → 25с = 51с` — выдумано.
|
||||
|
||||
**Правильно:** начальный RTO = 200ms, удвоение:
|
||||
```
|
||||
0.2с → 0.4с → 0.8с → 1.6с → 3.2с → 6.4с → 12.8с → 25.6с = 51с
|
||||
```
|
||||
8 ретрансмитов от 200ms = ровно 51 секунда (Linux kernel default).
|
||||
|
||||
### 2. DSR — неверное объяснение причины stall
|
||||
|
||||
**Было:** «двойной инкапсуляции return-трафика (главная причина stall)».
|
||||
|
||||
**Правильно:** stall на **входящем** трафике (клиент → pod), а не на return. DSR оптимизирует обратный путь, но не решает проблему inbound MTU black-hole.
|
||||
|
||||
### 3. Ping измеряет pod MTU, не underlay
|
||||
|
||||
Утверждение что `ping -M do` доказывает underlay = 1450 — неточно. Ping из пода измеряет MTU пода (1450 = 1500 − 50 Cilium). Физический underlay = 1450 подтверждён эмпирически (фикс сработал), а не прямым замером.
|
||||
|
||||
## Что верно
|
||||
|
||||
- Geneve overhead 50 байт: outer IP(20) + UDP(8) + Geneve(8) + inner Eth(14) = 50 ✓
|
||||
- `mtu: 1450` → pod MTU = 1400 ✓
|
||||
- `rollout restart ds/cilium` обязателен ✓
|
||||
- iptables может не работать с eBPF dataplane ✓
|
||||
- Корень проблемы и решение верны ✓
|
||||
@@ -0,0 +1,31 @@
|
||||
# Opus: финальная проверка PROBLEM-AND-SOLUTION.md
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Вердикт
|
||||
|
||||
Документ технически добротный. Диагноз и корневое решение (`mtu: 1450` + `Cluster` + `replicaCount: 2`) — правильные.
|
||||
|
||||
## Исправлено
|
||||
|
||||
1. **Ethernet 14** — уточнено: «инкапсулируемый L2-кадр 14» (внутренний Ethernet, не внешний)
|
||||
2. **51с = прикладной таймаут** — смягчено: «браузер/curl разрывает ожидание» вместо «соединение разрывается»
|
||||
|
||||
## Что подтверждено как верное
|
||||
|
||||
- PMTU blackhole как причина — классический сценарий
|
||||
- Cilium: `mtu` в configmap = underlay девайса, pod MTU = mtu − overhead
|
||||
- Geneve overhead = 50 байт (внешний IP 20 + UDP 8 + Geneve 8 + L2 14)
|
||||
- Решение: `mtu: 1450` → pod 1400 → Geneve-пакет 1450 → влазит в path 1450
|
||||
- `rollout restart ds/cilium` обязателен
|
||||
- `externalTrafficPolicy: Local` → `Cluster` после фикса MTU
|
||||
- DSR решает только обратный путь, stall на inbound
|
||||
- iptables может не работать с eBPF dataplane
|
||||
|
||||
## Мелкие замечания (не исправлены, допустимы)
|
||||
|
||||
- 200ms RTO — корректно для сценария (уже установленное TCP-соединение)
|
||||
- Тип туннеля (Geneve vs VXLAN) — overhead одинаков (50), математика не меняется
|
||||
- Замеры из таблицы результатов — эмпирические, из кода не подтвердить
|
||||
@@ -0,0 +1,77 @@
|
||||
# Opus: валидация MSS Clamping плана
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Ключевое исправление
|
||||
|
||||
В плане ошибка: `mtu: 1450` в cilium-config — это MTU **underlay-сети**, не туннеля. Cilium сам вычитает 50 байт на Geneve:
|
||||
|
||||
```
|
||||
mtu: 1450 → tunnel MTU = 1450 − 50 = 1400
|
||||
```
|
||||
|
||||
По умолчанию Cilium автодетектит underlay 1500 → туннель 1450. Если stall всё равно есть — underlay реально < 1500. **Число надо измерить, не гадать.**
|
||||
|
||||
---
|
||||
|
||||
## Ответы на вопросы
|
||||
|
||||
### 1. Корректно ли решение?
|
||||
|
||||
Да, направление верное. Но:
|
||||
- ConfigMap сам не применяется — нужен `rollout restart ds/cilium`
|
||||
- Число 1450 — гипотеза, нужен замер между нодами
|
||||
- `mtu` глобален на весь кластер
|
||||
- Существующие TCP-соединения не поменяют MSS — только новые
|
||||
|
||||
### 2. Что ещё нужно?
|
||||
|
||||
1. Измерить underlay MTU: `ping -M do -s 1472 <ip_другой_ноды>` (уменьшать пока не пройдёт)
|
||||
2. `kubectl rollout restart ds/cilium -n kube-system`
|
||||
3. Пересоздать поды приложения (ingress, drhider)
|
||||
4. Вернуть `externalTrafficPolicy: Cluster`
|
||||
|
||||
### 3. Как проверить?
|
||||
|
||||
```bash
|
||||
# Измерить до:
|
||||
ping -M do -s 1472 <ip_другой_ноды>
|
||||
tracepath <ip_другой_ноды>
|
||||
|
||||
# После изменения:
|
||||
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
|
||||
ip link show | grep -E 'cilium|geneve'
|
||||
tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0' -vv | grep mss
|
||||
|
||||
# Функционально: файл >5 MB без паузы
|
||||
```
|
||||
|
||||
### 4. Риск для других сервисов?
|
||||
|
||||
Понижение MTU безопасно (меньше = консервативнее). Небольшой рост числа пакетов, микро-потеря throughput. Функционально ничего не ломает. Рестарт агентов — возможны короткие блипы, лучше в окно.
|
||||
|
||||
### 5. iptables равноценен?
|
||||
|
||||
**Нет.** Cilium с eBPF обходит netfilter FORWARD — правило может не видеть пакеты. Надёжнее `cilium-config`. Если iptables — то на всех 4 нодах (kube-vip раздаёт VIP на все).
|
||||
|
||||
### 6. Вердикт
|
||||
|
||||
Путь правильный. Уточнённый план:
|
||||
1. Измерить path MTU между нодами
|
||||
2. Выставить `mtu` = измеренное значение (вероятно < 1500)
|
||||
3. Рестарт агентов + подов
|
||||
4. Вернуть `Cluster` policy
|
||||
|
||||
При `Cluster` проблема реплик исчезает сама (2 хватает).
|
||||
|
||||
---
|
||||
|
||||
## Моё мнение
|
||||
|
||||
Opus прав. План нужно дополнить **замером**. Без замера `1450` — пальцем в небо. Обновлю `MSS-CLAMPING-PLAN.md` с учётом:
|
||||
- Добавить шаг 0: измерение path MTU
|
||||
- Исправить формулу: `mtu` = underlay, туннель = `mtu - 50`
|
||||
- Добавить `rollout restart ds/cilium` + рестарт подов
|
||||
- Убрать iptables как основной вариант (ненадёжен с Cilium)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Запрос к Соннету — архитектура пофайловой обработки с одним ZIP (2026-07-13)
|
||||
|
||||
## Задача
|
||||
|
||||
DrHider (Flask + waitress). Ограничения:
|
||||
- Фронтенд должен слать файлы **по одному** (сеть не пропускает большие multipart)
|
||||
- Бэкенд должен обрабатывать **по одному** (без параллельных LLM-запросов)
|
||||
- Результат: **один ZIP** со всеми обфусцированными .md + **один общий** mapping.csv
|
||||
- **Никакой обработки данных в JS** — фронтенд только кнопки и скачивание
|
||||
|
||||
## Варианты
|
||||
|
||||
### А. Upload → Process
|
||||
1. `POST /api/upload` — один файл → `{session:"abc"}`
|
||||
2. Фронтенд циклом шлёт все, получает session
|
||||
3. `POST /api/process/abc` — бэкенд обрабатывает всё → ZIP
|
||||
4. Фронтенд скачивает ZIP
|
||||
|
||||
### Б. Один запрос, все файлы
|
||||
1. Фронтенд: один POST, все файлы в FormData
|
||||
2. Бэкенд: получил все → обработал по одному → ZIP
|
||||
3. Waitress держит долгое соединение, таймаут 600с
|
||||
|
||||
### В. SSE с прогрессом
|
||||
1. `POST /api/start` → `{session:"abc"}`
|
||||
2. `POST /api/upload/abc` — по одному файлу
|
||||
3. `GET /api/process/abc` — SSE стримит прогресс каждого файла и в конце ZIP
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Какой вариант правильный для Flask + waitress?
|
||||
2. Вариант Б — пройдёт ли большой multipart через ingress (6 файлов × 30-60 KB)?
|
||||
3. Если А — как чистить сессии? (пользователь закрыл вкладку, файлы остались в памяти)
|
||||
4. Нужен ли SSE или достаточно простого XHR с таймером?
|
||||
5. mapping.csv — генерировать на лету (по мере обработки) или после всех файлов?
|
||||
|
||||
## Сейчас работает (но сломано)
|
||||
|
||||
Сейчас: фронтенд шлёт по одному файлу, каждый получает отдельный ZIP. Пользователь хочет один ZIP в конце. JS обрабатывал ZIP'ы (распаковывал/перепаковывал) — это убрали, всё должно быть на бэкенде.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Запрос к Sonnet: анализ обфускации
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
**Задача:** Найти причину расхождения между ожидаемыми кодами замен и реальными заменами.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
`_next_token()` в `drhider/scanner.py` генерирует коды замен формата:
|
||||
- `Технология_0001`, `Прогресс_0002` (для компаний)
|
||||
- `+7_000_000_0001` (для телефонов)
|
||||
- `email_0001` (для email)
|
||||
- `ИНН_0001` (для ИНН)
|
||||
- `Иванов_0001` (для ФИО)
|
||||
|
||||
## Реальный результат — `TMP/mapping.csv`
|
||||
|
||||
```
|
||||
тип_данных,оригинал,замена
|
||||
phone,+7 (495) 789-41-35,+7 (495) 906-87-80 ← НЕ код замены, реальный номер
|
||||
email,info@nubes.ru,ypiorej@company.local ← НЕ код замены, реальный email
|
||||
company,ЗАО ****XXX****001****,АО «Прогресс» ← НЕ код замены, реальное название
|
||||
company,ЗАО XXX001,ООО «Альянс» ← НЕ код замены, реальное название
|
||||
inn_ul,ИНН 9706005293,ИНН 1846691249 ← НЕ код замены, реальный ИНН
|
||||
kpp,КПП 772401001,КПП 350443068 ← НЕ код замены, реальный КПП
|
||||
ks,Кор/сч 30101810400000000225,Кор/сч 30101201490272090211 ← НЕ код замены
|
||||
ogrn,ОГРН 1207700098759,ОГРН 1048672318430 ← НЕ код замены
|
||||
company,ООО "НУБЕС",ООО «Спектр» ← НЕ код замены
|
||||
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
|
||||
text,"Обязанности Заказчика\nСвоевременно",Степанов А.В.
|
||||
text,"Обязанности Исполнителя\nОказывать",Петров П.Е.
|
||||
text,"Ответственность Заказчика\nЗаказчик",Новиков А.М.
|
||||
text,"Ответственность Исполнителя\nИсполнитель",Попов О.М.
|
||||
company,ПАО Сбербанк,АО «Формат»
|
||||
```
|
||||
|
||||
**Ни одного кода замены формата `XXX_0000` в колонке «замена».**
|
||||
|
||||
## Реальный результат — `TMP/договор-XXX001-03700_obfuscated.md` (первые строки)
|
||||
|
||||
```markdown
|
||||
**АО «Прогресс»** ... и **ООО **"**НУБЕС**" ... Новиков И.В. ...
|
||||
```
|
||||
|
||||
- `Новиков И.В.` — НЕ заменён (должен быть `Иванов_0001`)
|
||||
- `ООО "НУБЕС"` — НЕ заменён в тексте (есть в mapping но замена не применена?)
|
||||
- `г. Москва, 1-я Стекольная 7с5` — адрес не заменён
|
||||
|
||||
## Файлы для анализа
|
||||
|
||||
**Обязательно прочитать:**
|
||||
1. `drhider/scanner.py` — `_next_token()`, `scan_regex()`, `scan_llm_ner()`, TYPE_POOLS, промпт LLM (строки 109-151)
|
||||
2. `drhider/obfuscator.py` — порядок вызова regex → LLM, как объединяется mapping (строки 63-95)
|
||||
3. `drhider/config.py` — ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN
|
||||
4. `drhider/llm_client.py` — `LLMClient.complete()`
|
||||
|
||||
**Прочитать для контекста:**
|
||||
5. `TMP/mapping.csv` — полный результат
|
||||
6. `TMP/договор-XXX001-03700_obfuscated.md` — обфусцированный файл
|
||||
7. `TMP/примеры_договоров_для_ИИ/договор-XXX001-03700.docx` — оригинал (для сверки)
|
||||
|
||||
**Не лезть:** `builder.py`, `extractor.py`, `replacer.py`, `session.py`, `templates/`, docs/, History/.
|
||||
|
||||
## Ключевые точки для проверки
|
||||
|
||||
### 1. `scanner.py` — `_next_token()` (стр. 63-82)
|
||||
|
||||
```python
|
||||
def _next_token(entity_type, counters):
|
||||
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}"
|
||||
```
|
||||
|
||||
Возвращает `Технология_0001`, `+7_000_000_0001`. **Никак не может вернуть** `АО «Прогресс»` или `+7 (495) 906-87-80`.
|
||||
|
||||
### 2. `scanner.py` — `scan_regex()` (стр. 89-108)
|
||||
|
||||
```python
|
||||
mapping[original] = _next_token(entity_type, counters)
|
||||
```
|
||||
|
||||
Всегда вызывает `_next_token`. Результат должен быть кодом замены.
|
||||
|
||||
### 3. `scanner.py` — `scan_llm_ner()` (стр. 109-179)
|
||||
|
||||
```python
|
||||
mapping[val] = _next_token(ent_type, counters)
|
||||
```
|
||||
|
||||
Тоже вызывает `_next_token`. Результат должен быть кодом замены.
|
||||
|
||||
### 4. `obfuscator.py` — порядок вызова (стр. 86-92)
|
||||
|
||||
```python
|
||||
scanner.scan_regex(text, self._mapping, self._counters) # сначала
|
||||
scanner.scan_llm_ner(all_texts, self._mapping, ...) # потом
|
||||
```
|
||||
|
||||
### 5. `config.py` — regex-паттерны:
|
||||
|
||||
- `phone`: `(?:\+7|8)[\s\-]?\(?\d{3}\)?...` — должно матчить `+7 (495) 789-41-35`
|
||||
- `email`: `[a-zA-Z0-9._%+-]+@...` — должно матчить `info@nubes.ru`
|
||||
- `inn_ul`: `ИНН\s*\d{10}` — должно матчить `ИНН 9706005293`
|
||||
- `COMPANY_PATTERN`: `(?:ООО|ЗАО|...)\s+(?:«[^»]+»|"[^"]+"|...)` — должно матчить `ООО "НУБЕС"` и `ЗАО "XXX001"`
|
||||
- `PERSON_PATTERN`: `[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.` — должно матчить `Новиков И.В.`
|
||||
|
||||
## Главный вопрос
|
||||
|
||||
Каждый путь (regex и LLM) вызывает `_next_token()`. `_next_token()` физически не может выдать `АО «Прогресс»` или `+7 (495) 906-87-80`. Откуда в `mapping.csv` берутся реалистичные замены? Кто и где их генерирует В ОБХОД `_next_token()`?
|
||||
@@ -0,0 +1,17 @@
|
||||
# Ответ Соннета — архитектура upload+process (2026-07-13)
|
||||
|
||||
## Рекомендация: Вариант А + блокирующий /process
|
||||
|
||||
```
|
||||
POST /api/upload → {session_id} (фронт шлёт по одному, N раз)
|
||||
POST /api/process/{sid} → {status:"done"} (блокирует до конца обработки)
|
||||
GET /api/download/{sid} → ZIP
|
||||
```
|
||||
|
||||
Без polling — `/process` просто блокирует XHR до готовности. Waitress держит.
|
||||
|
||||
## Детали
|
||||
- Mapping.csv — на лету, аккумулируется после каждого файла
|
||||
- Сессии — dict в памяти, TTL 30 минут через threading.Timer
|
||||
- Фронт: цикл upload → process → download. Три простых XHR.
|
||||
- Никакого JS-кода обработки данных.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sonnet: анализ обфускации — TMP-файлы vs текущий код
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Главный вывод
|
||||
|
||||
**TMP-файлы созданы СТАРОЙ версией кода.** Они не были сгенерированы текущим `_next_token()`-кодом. Это не баг в текущем коде — это артефакт предыдущей реализации.
|
||||
|
||||
---
|
||||
|
||||
## Доказательства
|
||||
|
||||
### 1. Имена файлов — старый формат
|
||||
|
||||
Текущий `obfuscator.py` генерирует имена вида `договор-НУБЕС001-03700.md` (меняет расширение). TMP-файлы: `договор-XXX001-03700_obfuscated.md` — суффикс `_obfuscated` и `XXX`-маскировка в имени. Текущий код так не делает.
|
||||
|
||||
### 2. Тип `text` — fallback из builder.py, не из сканера
|
||||
|
||||
`build_mapping_csv()` определяет тип постфактум по паттернам. Если ни один не совпал → `"text"`. Типа `text` нет в `TYPE_POOLS` scanner.py. Значит строки вида:
|
||||
```
|
||||
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
|
||||
```
|
||||
попали из старого кода, который не фильтровал роли (Исполнитель, Заказчик) — текущий LLM-промпт их явно исключает.
|
||||
|
||||
### 3. Одна сущность → разные замены
|
||||
|
||||
```
|
||||
email,info@nubes.ru,ypiorej@company.local ← файл 1
|
||||
email,info@nubes.ru,qifdckke@org.example.ru ← файл 2
|
||||
```
|
||||
|
||||
Текущий `TwoPassObfuscator` гарантирует глобальную согласованность. Старый код обрабатывал файлы по одному — отсюда расхождения.
|
||||
|
||||
### 4. Реалистичные замены невозможны из `_next_token()`
|
||||
|
||||
```python
|
||||
TYPE_POOLS = {
|
||||
"phone": (["+7_000_000"], "phone"), # → "+7_000_000_0001"
|
||||
"email": (["email"], "email"), # → "email_0001"
|
||||
}
|
||||
```
|
||||
|
||||
`+7 (495) 906-87-80` или `ypiorej@company.local` физически не могут выйти из этих пулов.
|
||||
|
||||
---
|
||||
|
||||
## Ответ на главный вопрос
|
||||
|
||||
> Кто и где генерирует замены в обход `_next_token()`?
|
||||
|
||||
**Никто в текущем коде.** Это артефакты старого кода (версии до v3). Текущий `_next_token()` написан позже.
|
||||
|
||||
---
|
||||
|
||||
## Что реально нужно проверить
|
||||
|
||||
| Вопрос | Как проверить |
|
||||
|--------|---------------|
|
||||
| Работает ли текущий код? | Прогнать оригиналы через v0.0.12, посмотреть mapping.csv |
|
||||
| Regex ловит все сущности? | Проверить `config.py` паттерны на примерах из TMP |
|
||||
| Адрес `115404, г. Москва...` — почему не заменён? | `ENTITY_PATTERNS` не содержит тип `address` |
|
||||
| `ООО "НУБЕС"` в тексте не заменён? | Возможно кавычки — regex матчит `«»` но не `""` |
|
||||
|
||||
---
|
||||
|
||||
## Итог
|
||||
|
||||
**Ложное противоречие** — сравнивались TMP-файлы (старый код) с текущим scanner.py. Нужно прогнать текущий код на тех же документах и посмотреть результат.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Sonnet: анализ багов обфускации — fix
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## Баг 1: `ООО "НУБЕС"` не заменено
|
||||
|
||||
**Причина:** DOCX → Markdown: `**ООО **"**НУБЕС**"`. Кавычка в отдельном run → `**` разрывает строку. Replacer ищет точное совпадение `ООО "НУБЕС"` — не находит.
|
||||
|
||||
**Фикс (replacer.py):** добавить `_md_tolerant_pattern()` — паттерн, допускающий `**` между частями строки. При промахе точного совпадения — фоллбэк с этим паттерном.
|
||||
|
||||
```python
|
||||
def _md_tolerant_pattern(original: str) -> str:
|
||||
MD = r'(?:\*{1,2})?'
|
||||
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
|
||||
return MD + MD.join(re.escape(p) for p in parts) + MD
|
||||
```
|
||||
|
||||
## Баг 2: `Новиков И.В.` → `ФИО`
|
||||
|
||||
**Причина:** В оригинале буквально `ФИО` — незаполненный шаблон. Не баг паттерна.
|
||||
|
||||
**Превентивный фикс (config.py):** `\s+` → `[\s\xa0]+` в PERSON_PATTERN — на случай неразрывных пробелов в DOCX.
|
||||
|
||||
```python
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
|
||||
)
|
||||
```
|
||||
|
||||
## Моё мнение
|
||||
|
||||
**Баг 1 — делать.** Логично, минимально, решает проблему Markdown-жирности.
|
||||
|
||||
**Баг 2 — `\xa0` делать** (превентивно). Сам `ФИО` — не баг, документ содержит незаполненный шаблон. Можно добавить `ФИО` в список игнорируемых (исключить из mapping), но не обязательно.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Диагностика ERR_CONNECTION_RESET в браузере
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
|
||||
---
|
||||
|
||||
## Хронология
|
||||
|
||||
### Фаза 1: MTU (решено)
|
||||
|
||||
Проблема: 63KB POST из браузера → `ERR_CONNECTION_RESET`. Причина: MTU 1500 + Geneve 50 = 1550 > underlay 1450.
|
||||
Решение: Cilium MTU = 1400. Проверено curl с ВМ (30/30).
|
||||
|
||||
### Фаза 2: HTTP/2
|
||||
|
||||
HTTP/2 → `ERR_HTTP2_PROTOCOL_ERROR`. HTTP/1.1 → `ERR_CONNECTION_RESET`.
|
||||
Решение: `use-http2: "false"`.
|
||||
|
||||
### Фаза 3: Поиск корневой причины RST
|
||||
|
||||
Баг: браузер → свежая страница → POST 63KB → `ERR_CONNECTION_RESET`.
|
||||
|
||||
## Полная матрица тестов
|
||||
|
||||
| # | Сценарий | Результат |
|
||||
|---|----------|-----------|
|
||||
| 1 | Браузер: свежая страница → POST 63KB | ❌ RST / `⏳ 100%` hang |
|
||||
| 2 | Браузер: свежая страница → GET (до Flask) → POST 63KB | ✅ 200 OK |
|
||||
| 3 | Браузер: свежая страница → POST 10B → POST 63KB | ✅ 200 OK |
|
||||
| 4 | Браузер: свежая страница → GET (nginx сам, 405) → POST 63KB | ❌ RST |
|
||||
| 5 | `curl` с ВМ (5.172.178.213) → POST 63KB | ✅ всегда <100ms |
|
||||
| 6 | Сразу после `kubectl rollout restart` ingress → браузер | ✅ работает |
|
||||
| 7 | Через 5-10 мин после рестарта → браузер | ❌ снова RST |
|
||||
|
||||
## Что исключено
|
||||
|
||||
| Гипотеза | Статус | Почему |
|
||||
|----------|--------|--------|
|
||||
| MTU | ❌ исключено | 1400, curl с ВМ всегда работает |
|
||||
| HTTP/2 | ❌ исключено | `use-http2: false`, та же проблема |
|
||||
| XHR vs fetch | ❌ исключено | оба метода падают одинаково |
|
||||
| `proxy_request_buffering: off` | ❌ исключено | `on` для `location /` |
|
||||
| `client-body-timeout: 10` | ❌ исключено | 63KB < 1 сек |
|
||||
| FD/worker-лимиты | ❌ исключено | 1M FD, 16K connections, 62MB mem |
|
||||
| OOM/Restart | ❌ исключено | 0 рестартов, нет OOMKilled |
|
||||
| Stale upstream keepalive | ❌ исключено | `keepalive=0` не помогло |
|
||||
| nginx→Flask (upstream) | ❌ исключено | curl с ВМ доказывает что работает |
|
||||
|
||||
## Эксперименты
|
||||
|
||||
### Перезапуск ingress временно чинит
|
||||
|
||||
После `kubectl rollout restart` — браузер работает. Через 5-10 мин — снова RST. Накопление состояния.
|
||||
|
||||
### Ресурсы в норме
|
||||
|
||||
```
|
||||
worker_rlimit_nofile: 1047552 (1M FD)
|
||||
worker_connections: 16384
|
||||
worker_processes: 4
|
||||
Memory: 62 MiB, CPU: 3-7m
|
||||
Restarts: 0, OOMKilled: нет
|
||||
```
|
||||
|
||||
### `upstream-keepalive-connections: 0` — НЕ фикс
|
||||
|
||||
Баг вернулся через 5-10 минут. Keepalive pool не при чём.
|
||||
|
||||
## Критическое противоречие
|
||||
|
||||
- `curl` с ВМ (тот же датацентр) → POST 63KB → **всегда 200 OK <100ms**
|
||||
- Браузер через 5-10 мин после рестарта ingress → **ERR_CONNECTION_RESET**
|
||||
|
||||
curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды.
|
||||
Разница: клиент (curl vs Chrome) и сетевой путь (локальная сеть ДЦ vs интернет/VPN).
|
||||
|
||||
## Текущая конфигурация (2026-07-14)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| `use-http2` | `"false"` |
|
||||
| `upstream-keepalive-connections` | `"0"` |
|
||||
| `error-log-level` | `debug` |
|
||||
| `proxy-body-size` | `1024m` |
|
||||
| `client-body-timeout` | `"10"` |
|
||||
| `keep-alive` | `"10"` |
|
||||
| Cilium MTU | `1400` |
|
||||
| `externalTrafficPolicy` | `Cluster` |
|
||||
| Ingress controller | `1.12.6` (Штурвал) |
|
||||
|
||||
---
|
||||
|
||||
## Вопрос к Claude (Sonnet/Opus)
|
||||
|
||||
### Симптом
|
||||
|
||||
Браузер (Chrome/Electron, Windows) → POST 63KB multipart/form-data → `ERR_CONNECTION_RESET` или зависание на `⏳ 100%`. Тело уходит полностью, ответ не приходит — TCP RST.
|
||||
|
||||
### Окружение
|
||||
|
||||
```
|
||||
Кластер: bare-metal K8s 1.34.1, 4 ноды (3 worker + 1 control-plane)
|
||||
CNI: Cilium Geneve, MTU подов = 1400
|
||||
VIP: kube-vip 185.247.187.151 (ARP на все 4 ноды)
|
||||
Ingress: nginx-ingress (Штурвал Helm), контроллер 1.12.6
|
||||
2 реплики, externalTrafficPolicy: Cluster
|
||||
Приложение: Flask/Waitress, 1 реплика (Штурвал managed)
|
||||
```
|
||||
|
||||
Конфиг ingress:
|
||||
```yaml
|
||||
use-http2: "false"
|
||||
upstream-keepalive-connections: "0"
|
||||
client-body-timeout: "10"
|
||||
keep-alive: "10"
|
||||
worker-processes: 4
|
||||
worker-rlimit-nofile: 1047552
|
||||
worker-connections: 16384
|
||||
proxy-body-size: 1024m
|
||||
proxy-request-buffering: on # для location /
|
||||
error-log-level: debug
|
||||
```
|
||||
|
||||
### Критическое противоречие
|
||||
|
||||
- `curl` с ВМ (5.172.178.213, тот же датацентр) → POST 63KB → **всегда 200 OK <100ms**
|
||||
- Браузер через 5-10 мин после рестарта ingress → **ERR_CONNECTION_RESET**
|
||||
- Сразу после рестарта ingress → браузер работает
|
||||
|
||||
curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды. Разница: клиент (curl vs Chrome) и сетевой путь до VIP (локальная сеть ДЦ vs интернет/VPN).
|
||||
|
||||
### Вопросы
|
||||
|
||||
1. Почему рестарт ingress помогает на 5-10 минут, а потом браузерные запросы снова получают RST, при том что curl с ВМ продолжает работать?
|
||||
|
||||
2. Может ли `externalTrafficPolicy: Cluster` + kube-vip создавать conntrack-записи, которые со временем ломают соединения из интернета (с другими TCP options/MSS) но не из локальной сети?
|
||||
|
||||
3. Стоит ли попробовать `externalTrafficPolicy: Local`? Какие риски?
|
||||
|
||||
4. Какие ещё эксперименты можно провести без tcpdump (нет доступа к нодам по SSH, нет tcpdump в подах)?
|
||||
|
||||
---
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Ответ от Claude
|
||||
- [ ] `externalTrafficPolicy: Local` — эксперимент
|
||||
- [ ] Воспроизвести с `curl` из WSL на машине пользователя
|
||||
- [ ] tcpdump через `kubectl debug` (требует координации)
|
||||
- [ ] Обновить ingress controller 1.12.6 → актуальный
|
||||
- [ ] Проверить `client-body-timeout: 60` + `keep-alive: 75`
|
||||
|
||||
---
|
||||
|
||||
## Ответ Claude (Sonnet) — 2026-07-14
|
||||
|
||||
### Два независимых механизма
|
||||
|
||||
Симптом объясняется наложением двух механизмов:
|
||||
|
||||
### Механизм 1: kube-vip ARP flapping + conntrack asymmetry (основной)
|
||||
|
||||
Именно это объясняет 5-10-минутное окно и разницу между browser и curl.
|
||||
|
||||
**Как работает с `externalTrafficPolicy: Cluster`:**
|
||||
|
||||
```
|
||||
Browser → VIP → Node N1 (kube-vip leader) → iptables DNAT+SNAT → nginx pod
|
||||
│
|
||||
└── conntrack entry создаётся на N1:
|
||||
(browser_ip:srcport → 185.247.187.151:443)
|
||||
↔ (N1_ip:eph → nginx_pod:443)
|
||||
```
|
||||
|
||||
Проблема возникает когда kube-vip меняет лидера:
|
||||
|
||||
```
|
||||
kube-vip: N1 → N2 (истёк lease или GARP от N2)
|
||||
Коммутатор: обновляет ARP-кэш, VIP теперь на N2
|
||||
|
||||
Browser (старое HTTP keep-alive соединение):
|
||||
→ PSH/ACK 63KB → попадает на N2
|
||||
N2: нет conntrack записи → TCP RST
|
||||
|
||||
curl (новое соединение):
|
||||
→ SYN → попадает на N2
|
||||
N2: создаёт новую conntrack → всё ОК
|
||||
```
|
||||
|
||||
**Почему 5-10 минут:** время жизни ARP-записи на коммутаторе или интервал переизбрания kube-vip лидера. "ARP на все 4 ноды" — периодические GARP-анонсы могут уходить с разных нод.
|
||||
|
||||
**Почему GET-to-Flask чинит последующий POST (тесты #2 vs #4):**
|
||||
- GET → Flask: создаётся *новое* TCP-соединение (новый SYN) → правильный conntrack на текущей ноде. POST идёт по *тому же* соединению → conntrack жив → OK.
|
||||
- GET → 405: nginx отвечает сам и закрывает соединение с `Connection: close`. POST создаёт *новое* соединение, но VIP уже на другой ноде → RST.
|
||||
|
||||
### Механизм 2: `keepalive_timeout: 10s` — слишком агрессивно
|
||||
|
||||
`keep-alive: "10"` в ConfigMap = `keepalive_timeout 10s` в nginx.
|
||||
|
||||
```
|
||||
T=0: Browser загружает страницу (GET /)
|
||||
T=10: nginx закрывает idle соединение (отправляет FIN)
|
||||
T=?: Пользователь заполняет форму (>10 сек → почти всегда)
|
||||
T=X: Browser отправляет POST по "живому" с его точки зрения соединению
|
||||
→ nginx уже закрыл socket → RST
|
||||
```
|
||||
|
||||
Браузер не всегда замечает FIN немедленно (TCP буферизация ОС). curl — каждый раз новое соединение.
|
||||
|
||||
### Почему curl всегда работает
|
||||
|
||||
| | Browser | curl |
|
||||
|---|---|---|
|
||||
| Соединение | HTTP keep-alive, долгоживущее | Новое для каждого запроса |
|
||||
| conntrack | Создано при SYN, может устареть | Новый SYN на каждый вызов |
|
||||
| keepalive | Держит idle >10s | Сразу POST → не ждёт |
|
||||
| VIP flap | Пакет идёт на новую ноду без conntrack | SYN на новой ноде создаёт conntrack |
|
||||
|
||||
### Ключевой инсайт
|
||||
|
||||
> Разница не в TCP-опциях (MSS, window size). Разница в **типе пакета**: curl всегда начинает с SYN → conntrack создаётся на правильной ноде. Browser держит соединение между запросами → когда VIP мигрирует, браузерные пакеты (ACK/PSH) приходят на новую ноду без предшествующего SYN → нет conntrack-записи → RST.
|
||||
|
||||
### Итоговая таблица причин
|
||||
|
||||
| Причина | Механизм | Почему не curl |
|
||||
|---|---|---|
|
||||
| kube-vip ARP lease renewal | Смена ноды → conntrack orphan | Новый SYN всегда |
|
||||
| `keepalive_timeout: 10s` | nginx закрывает idle conn → browser не замечает | Нет idle |
|
||||
|
||||
Оба фактора вместе: рестарт сбрасывает состояние, 5-10 минут → kube-vip переизбирается / браузер держит соединение дольше 10s → RST.
|
||||
|
||||
### Рекомендации
|
||||
|
||||
1. **`keep-alive: "75"`** — исключить механизм #2 (самое простое, безопасное)
|
||||
2. **`externalTrafficPolicy: Local`** — исключить механизм #1 (риск: потеря source IP для других сервисов)
|
||||
3. **JS: Connection warmer** — GET на Flask при загрузке страницы (уже доказано тестом #2)
|
||||
4. **JS: пересоздавать соединение** — использовать `fetch()` без keep-alive (credentials: 'omit' или отдельный subdomain)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Waitress + Connection: close — hop-by-hop заголовок
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
**Версия:** 0.0.21 → 0.0.22
|
||||
|
||||
---
|
||||
|
||||
## Ошибка
|
||||
|
||||
`Connection` — hop-by-hop заголовок. Waitress (WSGI) запрещает его использовать в приложении (PEP 3333).
|
||||
|
||||
```python
|
||||
# ❌ НЕЛЬЗЯ
|
||||
response.headers["Connection"] = "close"
|
||||
|
||||
# Ошибка:
|
||||
AssertionError: Connection is a "hop-by-hop" header; it cannot be used by a WSGI application (see PEP 3333)
|
||||
```
|
||||
|
||||
## Правило
|
||||
|
||||
**Никогда не добавлять `Connection` заголовок в Flask/Waitress-приложении.**
|
||||
Нужно закрывать соединения — только через nginx (upstream keepalive) или uWSGI/gunicorn.
|
||||
@@ -0,0 +1,48 @@
|
||||
# SSE: отлов отключения клиента, v0.0.18
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
**Версия:** 0.0.17 → 0.0.18
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
После 2-3 F5 во время обработки — загрузка падает с «Ошибка загрузки: Сеть».
|
||||
Причина: утечка потоков.
|
||||
|
||||
### Механика
|
||||
|
||||
1. `process_stream` создаёт SSE-генератор, занимающий поток Waitress (всего 8)
|
||||
2. `obfuscate_files()` выполняется ~30 сек (LLM-запросы)
|
||||
3. Пользователь делает F5 → TCP-соединение рвётся
|
||||
4. Генератор **не знает** что клиент отключился — продолжает обрабатывать файлы
|
||||
5. Поток занят, не освобождается
|
||||
6. 2-3 F5 → все 8 потоков заняты → новые запросы не принимаются → «Сеть»
|
||||
|
||||
### Почему WSGI не отключает
|
||||
|
||||
`yield` в генераторе пишет в буфер WSGI, а не в сокет. Буфер никуда не уходит
|
||||
(клиент отключён), но генератор об этом не узнаёт — `GeneratorExit` не кидается.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
Добавлен `try/except GeneratorExit` после каждого `yield` в `generate()`:
|
||||
|
||||
```python
|
||||
try:
|
||||
yield f"event: done..."
|
||||
except GeneratorExit:
|
||||
return # клиент отключился, освобождаем поток, НЕ обрабатываем остальные файлы
|
||||
```
|
||||
|
||||
Waitress при отключении клиента кидает `GeneratorExit` при попытке записи в
|
||||
закрытый сокет. Генератор ловит → выходит → поток освобождается.
|
||||
|
||||
---
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `site/routes/api_bp.py` — `try/except GeneratorExit` в `generate()`
|
||||
- `site/app.py` — `VERSION = "0.0.18"`
|
||||
@@ -0,0 +1,158 @@
|
||||
# Cluster Dump — 2026-07-15 17:00 MSK
|
||||
|
||||
## 1. НОДЫ (4 шт)
|
||||
|
||||
| Нода | Роль | Taints |
|
||||
|------|------|--------|
|
||||
| iot-naeel-control-plane-xb699 | control-plane | `NoSchedule:control-plane` |
|
||||
| iot-naeel-workers-vqphm-6f74n | workers | нет |
|
||||
| iot-naeel-workers-vqphm-bhbvs | workers | нет |
|
||||
| iot-naeel-workers-vqphm-v8zq4 | workers | нет |
|
||||
|
||||
## 2. INGRESS (shturval-ingress-controller)
|
||||
|
||||
### Поды (2 реплики)
|
||||
|
||||
| Под | Нода | Возраст |
|
||||
|-----|------|---------|
|
||||
| ...86jsp | control-plane-xb699 | ~6ч |
|
||||
| ...jm4kl | workers-vqphm-v8zq4 | ~6ч |
|
||||
|
||||
### Сервис
|
||||
- Тип: LoadBalancer
|
||||
- External IP: 185.247.187.151
|
||||
- `externalTrafficPolicy: Cluster`
|
||||
- NodePorts: 80:30739, 443:32391
|
||||
|
||||
### Ingress ConfigMap
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| keep-alive | **10** |
|
||||
| client-body-timeout | **10** |
|
||||
| client-header-timeout | **10** |
|
||||
| proxy-body-size | **8m** |
|
||||
| upstream-keepalive-connections | **0** |
|
||||
| use-http2 | false |
|
||||
| worker-processes | 4 |
|
||||
| error-log-level | info |
|
||||
|
||||
### Helm
|
||||
- Chart: shturval-ingress-controller-2.12.1
|
||||
- Ревизия 9 (сегодня 11:53) — `replicaCount: 2`, hostPort enabled
|
||||
- Ревизия 8 (сегодня 02:00) — то же самое
|
||||
- Более старых ревизий нет (почищены)
|
||||
|
||||
### Affinity (ingress deploy)
|
||||
```yaml
|
||||
preferredDuringScheduling:
|
||||
- weight: 10 → избегать control-plane
|
||||
- weight: 80 → предпочитать node-role: ingress
|
||||
- weight: 50 → предпочитать node-role: infra
|
||||
```
|
||||
|
||||
### События (каждые 51 сек!)
|
||||
```
|
||||
Warning SyncLoadBalancerFailed failed to ensure load balancer: no address pools could be found
|
||||
Normal EnsuringLoadBalancer Ensuring load balancer
|
||||
```
|
||||
|
||||
## 3. KUBE-VIP
|
||||
|
||||
### DaemonSet: shturval-vip (4 пода, на ВСЕХ 4 нодах)
|
||||
- Image: r.shturval.tech/kube-vip:v1.0.0
|
||||
- ARP mode: `vip_arp: true`
|
||||
- Election: `svc_election: true`
|
||||
- Leaderelection: `vip_leaderelection: false`
|
||||
- Lease: `sht-uservip-cp-lock`
|
||||
|
||||
### Cloud Provider: shturval-vip-provider (1 под, на bhbvs)
|
||||
- Image: r.shturval.tech/kubevip/kube-vip-cloud-provider:v0.0.12
|
||||
- Ищет ConfigMap: `kubevip` в неймспейсе `kube-system`
|
||||
|
||||
### ❌ ConfigMap `kubevip` в `kube-system` — **ПУСТОЙ!**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: kubevip
|
||||
namespace: kube-system
|
||||
data: {} # <-- НЕТ ДАННЫХ
|
||||
```
|
||||
|
||||
**Это причина ошибки «no address pools could be found»!**
|
||||
|
||||
## 4. DRHIDER (pythonk8s)
|
||||
|
||||
### Деплой
|
||||
- 1 реплика
|
||||
- Ревизия: 49
|
||||
- Инит-контейнер: клонирует из Git, потом pip install + python app.py
|
||||
- Ресурсы: 500m CPU, 1Gi RAM
|
||||
- Проба: TCP :5000
|
||||
|
||||
### ❌ PodAffinity (добавлен сегодня 16:46, непрошеный)
|
||||
```yaml
|
||||
requiredDuringScheduling:
|
||||
podAffinity → ingress pod на той же ноде
|
||||
```
|
||||
|
||||
### Сервис
|
||||
- ClusterIP: 10.102.171.63
|
||||
- Port 80 → targetPort 5000
|
||||
- `internalTrafficPolicy: Cluster`
|
||||
|
||||
### Ingress (drhider)
|
||||
- Host: drhider.pythonk8s.dev.nubes.ru
|
||||
- Аннотации: proxy-body-size=1024m, connect=120s, read/send=600s
|
||||
- TLS: letsencrypt-prod
|
||||
|
||||
### Поды
|
||||
| Под | Нода | Возраст |
|
||||
|-----|------|---------|
|
||||
| pythonk8s-6698c6c78-dkwsq | workers-vqphm-v8zq4 | ~15 мин |
|
||||
|
||||
Drhider на одной ноде с ingress-подом jm4kl.
|
||||
|
||||
## 5. ВСЕ INGRESS-РЕСУРСЫ
|
||||
|
||||
| Хост | Неймспейс | Возраст |
|
||||
|------|-----------|---------|
|
||||
| drhider.pythonk8s.dev.nubes.ru | 20a75175-... | 4д |
|
||||
| loadtest.pythonk8s.dev.nubes.ru | 9039a501-... | 5д |
|
||||
| contractor.pythonk8s.dev.nubes.ru | b4523aba-... | 7ч |
|
||||
| capire.kube5s.ru | default | 54д |
|
||||
| grafana.kube5s.ru | grafana | 66д |
|
||||
| keycloak.k8c.ru | keycloak | 96д |
|
||||
| qu.kube5s.ru | shared-sqs | 93д |
|
||||
| iot.kube5s.ru | sless | 94д |
|
||||
| terra.k8c.ru | terra | 92д |
|
||||
|
||||
## 6. CILIUM
|
||||
|
||||
4 пода (по одному на ноду), все Running. MTU был изменён на 1400 (по STATE).
|
||||
|
||||
## 7. КЛЮЧЕВЫЕ НАХОДКИ
|
||||
|
||||
### 🔴 ConfigMap `kubevip` пустой
|
||||
kube-vip-cloud-provider не может найти address pools → каждые 51 сек ошибка SyncLoadBalancerFailed. При этом `185.247.187.151` работает через ARP-mode kube-vip DaemonSet (не через cloud provider). Вероятно, это не fatal, но указывает на недонастроенную интеграцию.
|
||||
|
||||
### 🔴 Ingress ConfigMap отличается от ожидаемого
|
||||
- keep-alive: 10 (должен быть 75)
|
||||
- client-body-timeout: 10 (должен быть 60)
|
||||
- client-header-timeout: 10 (должен быть 30)
|
||||
- proxy-body-size: 8m (должен быть 1024m)
|
||||
|
||||
Но для drhider это переопределено в аннотациях ingress-ресурса.
|
||||
|
||||
### 🟡 Ingress 2 реплики на 4 нодах с kube-vip
|
||||
kube-vip анонсирует 185.247.187.151 на ВСЕХ 4 нодах, но ingress-поды только на 2. При `externalTrafficPolicy: Cluster` трафик на ноды без ingress: SNAT → кросс-нода → риск conntrack RST.
|
||||
|
||||
### 🟡 Helm: 2 апгрейда сегодня
|
||||
Ревизии 8 (02:00) и 9 (11:53) сегодня. Кто-то обновлял ingress. `replicaCount: 2` в обеих.
|
||||
|
||||
### 🟢 Аннотации drhider ingress — хорошие
|
||||
proxy-body-size=1024m, connect=120s, read/send=600s. Переопределяют дефолты ConfigMap.
|
||||
|
||||
### 🟢 Cilium — стабильный
|
||||
4 пода, все Running, MTU 1400.
|
||||
@@ -0,0 +1,204 @@
|
||||
# DrHider — итоговая диагностика ERR_CONNECTION_RESET
|
||||
|
||||
**Дата:** 2026-07-15 (финал)
|
||||
**Версия:** v0.0.29
|
||||
|
||||
---
|
||||
|
||||
## Резюме (моё мнение)
|
||||
|
||||
**`keep-alive: 10` — единственная причина ERR_CONNECTION_RESET.** Всё остальное (conntrack, hostPort, ARP flapping, svc_election) — шум, который мы искали 2 дня.
|
||||
|
||||
### Почему это так
|
||||
|
||||
VIP `185.247.187.151` жёстко привязан к control-plane-xb699 через аннотацию `kube-vip.io/vipHost`. Никакого flapping нет — трафик всегда приходит на одну ноду. Второй ingress-под на v8zq4 — избыточен, но не мешает.
|
||||
|
||||
Сценарий:
|
||||
1. Браузер загружает страницу — opens TCP
|
||||
2. Пользователь выбирает файлы — проходит >10 сек
|
||||
3. Nginx (keep-alive=10) закрывает idle соединение
|
||||
4. Браузер не замечает FIN (буферизация ОС)
|
||||
5. POST 63KB летит в мёртвый сокет → RST
|
||||
|
||||
### Почему предыдущие гипотезы неверны
|
||||
|
||||
| Гипотеза | Опровержение |
|
||||
|----------|-------------|
|
||||
| kube-vip ARP flapping | `vipHost` фиксирует VIP на одной ноде. Lease мёртв — плевать |
|
||||
| hostPort на 2 из 4 нод | Трафик не на все 4 ноды, а только на control-plane |
|
||||
| upstream keepalive stale | `keepalive=0` не помог |
|
||||
| MTU | Решено ещё 14-го, не при чём |
|
||||
| conntrack | ETPolicy Cluster не при чём — трафик на одну ноду |
|
||||
|
||||
### Что подтверждает keep-alive гипотезу
|
||||
|
||||
| Тест | Объяснение |
|
||||
|------|-----------|
|
||||
| Сразу после рестарта ingress → OK | Все соединения свежие |
|
||||
| Через 5-10 мин → RST | Соединения постарели >10с |
|
||||
| GET/health → POST → OK | Новое живое соединение |
|
||||
| curl с ВМ → всегда OK | curl открывает новый SYN каждый раз |
|
||||
| `keep-alive: 75` + JS warmup → OK на 10+ мин | Увеличили окно, warmup греет |
|
||||
|
||||
## Хронология
|
||||
|
||||
| Дата | Событие |
|
||||
|------|---------|
|
||||
| 2026-07-11 | Исходный деплой |
|
||||
| 2026-07-14 (день) | MTU fix (Cilium 1400) — решило 51с задержку |
|
||||
| 2026-07-14 (вечер) | RST диагностика: keep-alive 75 + JS warmup — частично |
|
||||
| 2026-07-15 (11:53) | Helm ревизия 9 — сброс ConfigMap на дефолты (keep-alive:10) |
|
||||
| 2026-07-15 | podAffinity `required` на drhider (ручной kubectl edit) |
|
||||
| 2026-07-15 (16:00) | Полная диагностика: hostPort vs cloud LB |
|
||||
|
||||
---
|
||||
|
||||
## Две независимые проблемы
|
||||
|
||||
### Проблема #1: MTU (решена 2026-07-14)
|
||||
|
||||
**Симптом:** 51с задержка при POST любого размера через внешний VIP.
|
||||
**Причина:** Geneve +50, underlay 1450, дефолт 1500 → фрагментация/дроп.
|
||||
**Решение:** Cilium `mtu: 1400`.
|
||||
|
||||
### Проблема #2: ERR_CONNECTION_RESET (хост порт, решена 2026-07-15)
|
||||
|
||||
**Симптом:** Браузер получает TCP RST при POST 63KB через 5-10 мин после рестарта ingress.
|
||||
**Ранее считалось:** keep-alive 10с или kube-vip conntrack.
|
||||
**Реальная причина:** Штурвал балансирует трафик на **все 4 ноды**, но ingress стоит `replicas: 2` с **hostPort** — порт 80/443 открыт только на 2 нодах.
|
||||
|
||||
```
|
||||
Browser → cloud LB → любая из 4 нод
|
||||
├── нода с ingress-подом → hostPort 80 → nginx → ✅
|
||||
└── нода БЕЗ ingress-пода → порт 80 не слушается → RST ❌
|
||||
```
|
||||
|
||||
**50% запросов попадает на пустую ноду → RST.**
|
||||
|
||||
---
|
||||
|
||||
## Подтверждающие данные
|
||||
|
||||
### Helm ревизии (дамп)
|
||||
|
||||
| Ревизия | Время | replicaCount | Другие изменения |
|
||||
|---------|-------|-------------|------------------|
|
||||
| 1 (деплой) | 2026-07-11 | 2 | — |
|
||||
| 8 | 2026-07-15 02:00 | **2** | — |
|
||||
| 9 | 2026-07-15 11:53 | **2** | — |
|
||||
|
||||
Helm **не менял** replicaCount. Всегда 2. Но ConfigMap сбрасывал на дефолты (keep-alive: 10, proxy-body-size: 8m и т.д.).
|
||||
|
||||
### Ingress ConfigMap (последствия Helm upgrade)
|
||||
|
||||
```yaml
|
||||
# Было (ручные правки 2026-07-14) → Стало (после ревизии 9)
|
||||
keep-alive: "75" → "10" # вернулось на дефолт Штурвала
|
||||
proxy-body-size: "1024m" → "8m" # тоже сброшено
|
||||
error-log-level: debug → info # сброшено
|
||||
upstream-keepalive-connections: "0" → сохранилось
|
||||
```
|
||||
|
||||
### kube-vip svc_election — мёртв
|
||||
|
||||
```yaml
|
||||
# Lease ingress/kubevip-shturval-ingress-controller-controller
|
||||
holderIdentity: "" # пусто — никто не держит
|
||||
leaseDurationSeconds: 1 # аномально короткий (норма 15-30)
|
||||
renewTime: 2026-07-11T14:55:28Z # 4 дня назад не обновлялся
|
||||
leaseTransitions: 17 # но было 17 переходов
|
||||
```
|
||||
|
||||
svc_election никогда не работал. VIP назначен через `ipMode: VIP` в статусе сервиса, работает на уровне облака (BGP/маршрутизация).
|
||||
|
||||
### hostPort
|
||||
|
||||
```yaml
|
||||
# В шаблоне пода ingress (Helm template)
|
||||
hostPort: 80
|
||||
hostPort: 443
|
||||
```
|
||||
|
||||
Включён в ревизиях 8 и 9. hostPort = порт слушается ТОЛЬКО на нодах где стоит под.
|
||||
|
||||
### podAffinity на drhider — временный костыль
|
||||
|
||||
```json
|
||||
{
|
||||
"podAffinity": {
|
||||
"requiredDuringSchedulingIgnoredDuringExecution": [{
|
||||
"labelSelector": {"matchLabels": {"app.kubernetes.io/name": "shturval-ingress-controller"}},
|
||||
"namespaceSelector": {"matchLabels": {"name": "ingress"}},
|
||||
"topologyKey": "kubernetes.io/hostname"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Добавлен через `kubectl edit`, НЕ через Helm. Привязывает drhider к ноде с ingress-подом. Это НЕ лечит RST (RST на уровне VIP→нода, не на уровне нода→drhider). При следующем `helm upgrade` — исчезнет.
|
||||
|
||||
### Kube-vip DaemonSet — не участвует
|
||||
|
||||
```yaml
|
||||
vip_arp: true # ARP включён
|
||||
vip_leaderelection: false # нет CP election
|
||||
svc_election: true # должен быть — но lease мёртв
|
||||
```
|
||||
|
||||
Даемоны не логгируют VIP `185.247.187.151`. Трафик распределяет облако, не kube-vip.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
**Единственное полное решение:** ingress-под на каждой ноде куда приходит трафик.
|
||||
|
||||
### Вариант 1 (рекомендуемый): увеличить replicas
|
||||
|
||||
```yaml
|
||||
# Helm values
|
||||
shturval-ingress-controller:
|
||||
replicaCount: 4
|
||||
```
|
||||
|
||||
Или выяснить у DevOps сколько нод в LB-пуле Штурвала и поставить `replicaCount = число нод`.
|
||||
|
||||
### Вариант 2 (если балансировка на все worker'ы): DaemonSet
|
||||
|
||||
```yaml
|
||||
# Helm values
|
||||
shturval-ingress-controller:
|
||||
kind: DaemonSet
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что делать сейчас (ручной воркараунд)
|
||||
|
||||
### 1. Починить ConfigMap (срочно)
|
||||
|
||||
```bash
|
||||
kubectl patch configmap -n ingress shturval-ingress-controller-controller --type merge \
|
||||
-p '{"data":{"keep-alive":"75","proxy-body-size":"1024m","client-body-timeout":"60","client-header-timeout":"30"}}'
|
||||
```
|
||||
|
||||
### 2. Масштабировать ingress (временный фикс)
|
||||
|
||||
```bash
|
||||
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
|
||||
```
|
||||
|
||||
Поды раскидаются по всем 4 нодам → hostPort на всех → 0% RST.
|
||||
|
||||
⚠️ **Предупреждение:** после следующего `helm upgrade`:
|
||||
- ConfigMap сбросится (нужно править Helm values)
|
||||
- replicas вернётся на 2
|
||||
- podAffinity на drhider исчезнет
|
||||
|
||||
---
|
||||
|
||||
## Вопросы к DevOps
|
||||
|
||||
1. **Сколько нод в LB-пуле Штурвала?** (на какие ноды облако направляет трафик ingress?)
|
||||
2. **Можно ли увеличить `replicaCount` до числа нод?**
|
||||
3. **Как правильно изменить Helm values для ingress?** (чтобы ConfigMap не сбрасывался при upgrade)
|
||||
@@ -0,0 +1,135 @@
|
||||
# Проблема загрузки файлов: диагностика и решение
|
||||
|
||||
**Дата:** 2026-07-16
|
||||
**Версия приложения:** v0.0.31
|
||||
**Кластер:** `iot-naeel` (4 ноды, Cilium Geneve, kube-vip, Штурвал)
|
||||
|
||||
---
|
||||
|
||||
## Проблема 1: 51-секундная задержка (MTU)
|
||||
|
||||
### Суть
|
||||
При передаче POST из внешней сети — пауза 51с на любом объёме данных. Внутри кластера — мгновенно.
|
||||
|
||||
### Причина
|
||||
Underlay MTU = 1450. Cilium Geneve добавляет 50 байт. Дефолтный MTU пода = 1500.
|
||||
Cross-node пакет = 1500+50 = 1550 → фрагментация → дроп → ядро ждёт повторной передачи 51с.
|
||||
|
||||
### Решение
|
||||
`cilium-config` → `mtu: 1400`. 1400+50 = 1450 = underlay.
|
||||
Задокументировано 2026-07-13, применено 2026-07-14.
|
||||
|
||||
---
|
||||
|
||||
## Проблема 2: ERR_CONNECTION_RESET после фикса MTU
|
||||
|
||||
### Суть
|
||||
curl с ВМ работает. Браузер — `ERR_CONNECTION_RESET` или `⏳ 100%`.
|
||||
После `rollout restart` ingress — работает 5-10 минут, потом снова RST.
|
||||
|
||||
### Ложные следы (опровергнуты)
|
||||
|
||||
| Гипотеза | Почему неверна |
|
||||
|----------|---------------|
|
||||
| kube-vip ARP flapping | `kube-vip.io/vipHost: control-plane` — VIP жёстко на одной ноде |
|
||||
| hostPort на 2 из 4 нод | Трафик только на control-plane, не на все 4 |
|
||||
| upstream keepalive stale | `upstream-keepalive-connections: 0` не помог |
|
||||
| conntrack | ETPolicy Cluster не при чём — трафик на одну ноду |
|
||||
| MTU | Решено в проблеме 1 |
|
||||
| HTTP/2 | `ERR_HTTP2_PROTOCOL_ERROR` → отключили → `ERR_CONNECTION_RESET` — симптом тот же |
|
||||
|
||||
### Истинная причина
|
||||
**`keep-alive: 10`** — Штурвал ставил nginx `keep-alive: 10` (вместо дефолта 75).
|
||||
Сценарий:
|
||||
1. Браузер грузит страницу — TCP открыт
|
||||
2. Пользователь выбирает файлы (>10с) — nginx закрывает idle соединение
|
||||
3. Браузер не замечает FIN (буферизация ОС)
|
||||
4. POST 63KB летит в закрытый сокет → TCP RST
|
||||
|
||||
### Решение (применено в платформе Штурвала 2026-07-15)
|
||||
|
||||
Через `ShturvalServiceConfig` (не kubectl — ArgoCD откатывает):
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
config:
|
||||
keep-alive: "75"
|
||||
proxy-body-size: "1024m"
|
||||
client-body-timeout: "60"
|
||||
client-header-timeout: "30"
|
||||
hostPort:
|
||||
enabled: true
|
||||
replicaCount: 2
|
||||
service:
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
Также в коде — `fetch('/health')` при загрузке страницы (прогрев upstream).
|
||||
|
||||
### v0.0.30 — .doc в диалоге выбора файлов
|
||||
Добавлен `accept=".doc,.docx,.pdf,.txt,.zip"` в `<input type="file">`.
|
||||
`doc_to_markdown()` переписан: вместо `soffice` (LibreOffice на ВМ) — HTTP-вызов `liberta.containerk8s.dev.nubes.ru/convert` через `httpx`.
|
||||
|
||||
### v0.0.31 — .doc обрабатывается обфускацией
|
||||
`.doc` теперь не просто конвертируется, а проходит полную обфускацию (замена сущностей, mapping.csv). В архиве — `.md` с заменённым текстом, не исходный `.doc`.
|
||||
|
||||
### Результаты тестов (v0.0.29, 2026-07-15)
|
||||
|
||||
| Тест | Результат |
|
||||
|------|-----------|
|
||||
| 10× POST 63KB (свежие страницы) | **10/10 OK:200** |
|
||||
| UI: 5 файлов + SSE + ZIP + CSV | ✅ полный цикл |
|
||||
| 5× POST через 75с (keep-alive окно) | **5/5 OK:200** |
|
||||
| 5MB PDF через curl | ✅ 200 OK, 0.62с |
|
||||
| **ИТОГО** | **25/25 успешно, 0 RST** |
|
||||
|
||||
---
|
||||
|
||||
## Итоговая конфигурация кластера
|
||||
|
||||
### Ingress (через платформу Штурвала, несбрасываемо ArgoCD)
|
||||
|
||||
| Параметр | Дефолт nginx | Было (Штурвал) | Сейчас |
|
||||
|----------|-------------|----------------|--------|
|
||||
| `keep-alive` | 75 | **10** | **75** |
|
||||
| `proxy-body-size` | 8m | 8m | **1024m** |
|
||||
| `client-body-timeout` | 60 | **10** | **60** |
|
||||
| `client-header-timeout` | 60 | **10** | **30** |
|
||||
| `use-http2` | true | true | **false** |
|
||||
| `upstream-keepalive-connections` | 0 | 50 | 0 |
|
||||
| `error-log-level` | info | info | **debug** (вернуть info) |
|
||||
|
||||
### Cilium (через kubectl, не сбрасывается)
|
||||
|
||||
| Параметр | Дефолт | Сейчас |
|
||||
|----------|--------|--------|
|
||||
| `mtu` | 1500 | **1400** |
|
||||
|
||||
### Приложение (drhider v0.0.29)
|
||||
|
||||
```python
|
||||
# site/app.py
|
||||
VERSION = "0.0.29"
|
||||
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
|
||||
```
|
||||
|
||||
```javascript
|
||||
// site/templates/index.html — warmup при загрузке
|
||||
window.addEventListener('load', () => {
|
||||
sf = []; fi.value = ''; rr();
|
||||
fetch('/health').catch(() => {}); // прогрев upstream
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| Файл | Что |
|
||||
|------|-----|
|
||||
| `docs/ARCHITECTURE.md` | Архитектура + требования к Flask |
|
||||
| `docs/MIGRATION-GUIDE.md` | Инструкция переноса UI на Flask |
|
||||
| `History/2026-07-14-browser-rst-debug.md` | Полная история диагностики RST |
|
||||
| `History/2026-07-15-rst-root-cause.md` | Финальный диагноз |
|
||||
| `History/2026-07-15-cluster-dump.md` | Дамп кластера |
|
||||
| `STATE-2026-07-15.md` | Состояние проекта на 2026-07-15 |
|
||||
@@ -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,107 @@
|
||||
# DrHider — состояние на 2026-07-15
|
||||
|
||||
## Текущая версия: v0.0.29 (задеплоена)
|
||||
|
||||
## Что работает
|
||||
|
||||
- Загрузка файлов (.docx, .pdf, .txt, .zip)
|
||||
- Обфускация через LLM (два прохода: regex + LLM NER)
|
||||
- SSE-прогресс обработки
|
||||
- Скачивание ZIP + CSV (имена из Content-Disposition, не хардкод)
|
||||
- Выбор файлов добавляет (не стирает), с дедупликацией
|
||||
- При F5/закрытии — XHR и EventSource обрываются
|
||||
|
||||
## Что НЕ работает (баг вернулся)
|
||||
|
||||
**ERR_CONNECTION_RESET / Таймаут 30с при загрузке из браузера.**
|
||||
|
||||
Причина: kube-vip ARP flapping + `externalTrafficPolicy: Cluster` + 4 ingress пода.
|
||||
Браузерное keep-alive соединение попадает на ноду без conntrack → RST.
|
||||
|
||||
### Что пробовали
|
||||
|
||||
| Фикс | Результат |
|
||||
|------|-----------|
|
||||
| `keep-alive: 10 → 75` | Помогло, но временно |
|
||||
| JS: `fetch('/health')` при загрузке (warmup) | Помогло, но временно |
|
||||
| `upstream-keepalive-connections: 0` | Не помогло |
|
||||
| Перезапуск ingress | Помогает на 5-10 мин, потом снова |
|
||||
|
||||
### Что НЕ пробовали (главный кандидат)
|
||||
|
||||
**`externalTrafficPolicy: Local`** — убирает лишний хоп kube-proxy, conntrack создаётся на той же ноде где ingress под. Это должно добить проблему навсегда.
|
||||
|
||||
Но затрагивает ВСЕ сервисы на этом ingress, не только drhider.
|
||||
|
||||
## Текущая конфигурация кластера
|
||||
|
||||
### Ingress ConfigMap
|
||||
|
||||
```
|
||||
keep-alive: "75" # было 10 — Штурвал
|
||||
client-body-timeout: "10" # было 10 — надо 60
|
||||
client-header-timeout: "10" # было 10 — надо 30
|
||||
use-http2: "false" # можно вернуть true
|
||||
proxy-body-size: "1024m"
|
||||
upstream-keepalive-connections: "0"
|
||||
error-log-level: "debug" # вернуть info
|
||||
```
|
||||
|
||||
### Cilium
|
||||
|
||||
```
|
||||
mtu: 1400 # было 1500 — Geneve overhead
|
||||
```
|
||||
|
||||
### Ingress поды (4 шт — что-то изменило кол-во)
|
||||
|
||||
```
|
||||
control-plane: 172.16.0.73 (3h13m)
|
||||
v8zq4: 172.16.3.149 (3h13m)
|
||||
bhbvs: 172.16.2.115 (29m) ← новые
|
||||
6f74n: 172.16.1.227 (29m) ← новые
|
||||
```
|
||||
|
||||
### Drhider под
|
||||
|
||||
```
|
||||
Неймспейс: 20a75175-a58c-49cb-b8fa-e86367b1a8dc
|
||||
Deployment: pythonk8s (1 реплика)
|
||||
```
|
||||
|
||||
## Ключевые файлы
|
||||
|
||||
| Файл | Что |
|
||||
|------|-----|
|
||||
| `site/templates/index.html` | Весь фронтенд (HTML/CSS/JS) |
|
||||
| `site/routes/api_bp.py` | API: upload, process_stream (SSE), download, csv |
|
||||
| `site/routes/health_bp.py` | `/health` — liveness probe |
|
||||
| `site/app.py` | Точка входа Flask, VERSION, MAX_CONTENT_LENGTH, no_cache |
|
||||
| `site/session.py` | In-memory сессии |
|
||||
| `drhider/*.py` | Бизнес-логика обфускации |
|
||||
| `docs/ARCHITECTURE.md` | Архитектура, требования Flask, имена файлов |
|
||||
| `docs/MIGRATION-GUIDE.md` | Инструкция миграции UI на Flask |
|
||||
| `PROBLEM-AND-SOLUTION.md` | MTU fix, конфигурация для DevOps |
|
||||
| `History/2026-07-14-browser-rst-debug.md` | Полная история диагностики RST |
|
||||
|
||||
## Как деплоить
|
||||
|
||||
```bash
|
||||
# После правок кода:
|
||||
cd /home/naeel/nubes/drhider
|
||||
# Bump VERSION в site/app.py
|
||||
# Bump версию в index.html (два места: заголовок .ver и в JS если есть)
|
||||
git add -A && git commit -m "..." && git push
|
||||
ssh ... naeel@5.172.178.213 "kubectl rollout restart deploy -n 20a75175... pythonk8s"
|
||||
```
|
||||
|
||||
## Что делать дальше
|
||||
|
||||
1. **Срочно:** перезапустить ingress чтобы пользователь мог работать сейчас:
|
||||
```bash
|
||||
kubectl rollout restart deploy -n ingress shturval-ingress-controller-controller
|
||||
```
|
||||
|
||||
2. **Навсегда:** `externalTrafficPolicy: Local` — обсудить с DevOps
|
||||
|
||||
3. **Докупить:** обновить PROBLEM-AND-SOLUTION.md после применения Local
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
тип_данных,оригинал,замена
|
||||
text,"""115404"," г. Москва, ул. 1-я Стекольная, д.7с2"",Красноярск_0001
|
||||
"
|
||||
text,"""АО """"****XXX****003****""""""","Альянс_0001
|
||||
"
|
||||
text,"""АО """"XXX003""""""","Стандарт_0001
|
||||
"
|
||||
text,"""ЗАО """"****XXX****001****""""""","Сервис_0001
|
||||
"
|
||||
text,"""ЗАО """"XXX001""""""","Прогресс_0001
|
||||
"
|
||||
text,"""ООО """"НУБЕС""""""","Меридиан_0001
|
||||
"
|
||||
phone,+7 (495) 789-41-35,"+7_000_000_0001
|
||||
"
|
||||
text,01300_2,"Договор_0004
|
||||
"
|
||||
text,044525225,"БИК_0001
|
||||
"
|
||||
text,FILE: допник-1-XXX002-01200_3.doc,ID_0001
|
||||
email,info@nubes.ru,"email_0001
|
||||
"
|
||||
company,ЗАО «ХХХ001»,"Сервис_0001
|
||||
"
|
||||
inn_ul,ИНН 9706005293,"ИНН_0001
|
||||
"
|
||||
text,Исполнителем Заказчику Сторонами,"Кузнецов_0002
|
||||
"
|
||||
kpp,КПП 772401001,"КПП_0001
|
||||
"
|
||||
ks,Кор/сч 30101810400000000225,"КС_0001
|
||||
"
|
||||
ogrn,ОГРН 1207700098759,"ОГРН_0001
|
||||
"
|
||||
|
@@ -0,0 +1,286 @@
|
||||
**************Договор******** ****************на оказание технологических услуг******
|
||||
Договор_0001
|
||||
|
||||
**Сервис_0001** именуемое в дальнейшем "Заказчик", в лице Генерального директора ФИО, действующего на основании Устава, и **ООО **"**НУБЕС**", именуемое в дальнейшем "Исполнитель", в лице Генерального директора Николаев_0001, действующего на основании Устава, с другой стороны, вместе именуемые "Стороны", заключили настоящий Договор о нижеследующем:
|
||||
# ТЕРМИНЫ И ОПРЕДЕЛЕНИЯ
|
||||
**Услуги **– оказываемые Исполнителем Заказчику технологические услуги в области компьютерных и телекоммуникационных технологий и информационной безопасности, размещение серверов на технической площадке (colocation), организация канала связи, обеспечение бесперебойного электропитания и охлаждения, предоставление готовой технической инфраструктуры, профессиональное техническое обслуживание оборудования в режиме 24/7/365, подключение к сети Интернет по высокоскоростным каналам связи, оказываемые Исполнителем Заказчику. Услуги подразделяются на Инсталляционные, Абонентские (ежемесячные), Тарифицируемые и разовые (дополнительные). Полный перечень и описание оказываемых Услуг приводятся в Спецификациях, утверждаемых Сторонами в виде Приложения №1 к Договору, являющегося его неотъемлемой частью (далее – «Спецификация»).
|
||||
**Инсталляционные услуги** – Услуги по установке, настройке и включению в сетевую инфраструктуру Исполнителя оборудования и/или программного обеспечения, необходимого для предоставления Заказчику Абонентских и Тарифицируемых Услуг.
|
||||
**Абонентские услуги** – Услуги, указанные в Спецификации, предоставляемые ежемесячно в течение всего срока действия Договора.
|
||||
**Тарифицируемые услуги** – Услуги, оказываемые в дополнение к Абонентским, количество объектов обслуживания для которых непостоянно. Ежемесячная стоимость услуги определяется в конце каждого отчетного периода, в соответствии с ценой услуги за один объект обслуживания указанной в Спецификации.
|
||||
**Разовые (дополнительные) услуги** – Услуги, которые предоставляются на основании письменного запроса Заказчика, оформленного в соответствии Регламентом взаимодействия Сторон в процессе оказания Услуг, утверждаемом Сторонами в виде Приложения №3 к настоящему Договору (далее – «**Регламент взаимодействия Сторон в процессе оказания Услуг**»).
|
||||
**Этап оказания Услуг** - очередной этап исполнения обязательств по Договору, Отчетный период или период предоставления Услуг, предусмотренных Договором.
|
||||
**Информационная система (далее – «ИС»)** – совокупность прикладного программного обеспечения и информационного наполнения, обладающая полезными функциями, необходимыми Заказчику. ИС может являться собственностью Заказчика или предоставляться ему в пользование Исполнителем на основании отдельно заключенного Сторонами соглашения.
|
||||
**Облачные ресурсы – **виртуальные ресурсы ПАК (виртуальные серверы, резервное копирование данных), а также типовое программное обеспечение и специальное программное обеспечение, необходимое для выполнения конкретных функций. Состав и конфигурация предоставляемого ресурса и функционала ПАК Исполнителя приведены в Спецификации.
|
||||
**Программно-аппаратный комплекс (далее – «ПАК»)** – совокупность оборудования и программного обеспечения, необходимого для оказания предусмотренных Договором Услуг.
|
||||
**Технологическая площадка** – специализированное помещение, в котором размещается серверное и сетевое оборудование Исполнителя и предоставляются Услуги.
|
||||
**Конфиденциальная информация -** означает всю информацию, не являющуюся общедоступной, предоставляемую одной Стороной другой Стороне в связи с настоящим Договором, за исключением той информации, которую Стороны определяют, как не конфиденциальная и которая не является конфиденциальной в соответствии с действующим законодательством РФ.
|
||||
**Отчетный период -** означает календарный месяц.
|
||||
**Рабочий день** означает день, официально считающийся в текущем году рабочим на территории Российской Федерации при пятидневной рабочей неделе.
|
||||
**Уровень обслуживания** – совокупность параметров качества оказываемых Услуг, описывается в Соглашении об уровне обслуживания, утверждаемом Сторонами в виде Приложения № 2 к Договору (далее – «**Соглашение об уровне обслуживания**») и Приложении № 4 к Договору (далее – «**Технические условия**»), являющихся неотъемлемой частью Договора.
|
||||
**УПД **- универсальный передаточный документ, подтверждающий выполнение работ и/или оказание Услуг Исполнителем.** **
|
||||
**Криптовалютный майнинг** - процесс использования вычислительных мощностей для решения алгоритмических задач, направленных на создание новых блоков в блокчейне и получение вознаграждения в виде криптовалюты.
|
||||
# ПРЕДМЕТ ДОГОВОРА
|
||||
Исполнитель обязуется оказывать Заказчику технологические Услуги, указанные в Спецификации, а Заказчик обязуется принимать и оплачивать Услуги в объеме и в сроки согласно условиям настоящего Договора и Приложений к нему.
|
||||
Место оказания услуг – технологическая площадка, Российская Федерация, г. Москва, 1-я Стекольная 7с5, г. Москва, 1-я Стекольная 7с6.
|
||||
Период оказания Услуг по настоящему Договору, подлежащий оплате Заказчиком, начинается с даты подписания Сторонами Акта начала оказания Услуг или с даты начала Услуг, согласованной Сторонами в Спецификации, и заканчивается в момент истечения срока действия, прекращения или расторжения настоящего Договора, если иное не установлено настоящим Договором или письменно не согласованно Сторонами.
|
||||
Технические требования к оказанию Услуг указаны в Соглашении об уровне обслуживания и Технических условиях.
|
||||
# ПРАВА И ОБЯЗАННОСТИ СТОРОН
|
||||
## Степанов_0001 определять количество специалистов и график их работы, объем вычислительных ресурсов и программного обеспечения, а также других ресурсов, необходимых для оказания Услуг. В случае необходимости Исполнитель имеет право привлекать для исполнения обязательств по настоящему Договору третьи стороны, при этом Исполнитель в полном объеме несет ответственность за действия привлеченных Исполнителем третьих лиц перед Заказчиком.
|
||||
На основании Универсального передаточного документа (далее – «УПД»), подписываемого обеими Сторонами и счета на оплату, потребовать дополнительной оплаты затраченных ресурсов, если по вине или инициативе Заказчика в ходе исполнения настоящего Договора объем фактически оказанных Услуг превысил объем Услуг, предусмотренный условиями настоящего Договора.
|
||||
Изменять в одностороннем порядке не чаще одного раза в 6 (шесть) месяцев цены на предоставляемые им Услуги с обязательным уведомлением Заказчика в письменной форме в срок не менее чем за тридцать календарных дней до вступления в силу новых цен. Отсутствие в течение вышеуказанного срока письменного отказа от новых цен со стороны Заказчика считается его согласием с указанными изменениями. Об изменении цен по Договору Стороны подписывают дополнительное соглашение. Заказчик обязуется в течение 15 рабочих дней с даты получения уведомления Исполнителя и Дополнительного соглашения об изменении цены по Договору подписать такое Дополнительное соглашение, либо направить Исполнителю письменный мотивированный отказ от его подписания. В случае отказа Заказчика от подписания Дополнительного соглашения на условиях, предлагаемых Исполнителем, а также в случае, если в указанный срок Заказчик не направит Исполнителю письменный мотивированный отказ от подписания Дополнительного соглашения, Исполнитель вправе расторгнуть настоящий Договор в порядке, предусмотренном п.15.11 настоящего Договора.
|
||||
При оказании Услуг осуществлять взаимодействие с представителями Заказчика и доступ к оборудованию Заказчика, размещаемому на Технологической площадке для эксплуатационно-технического обслуживания в соответствии с Регламентом взаимодействия сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
|
||||
Запрашивать у Заказчика информацию (материалы, документы и сведения технического и организационного характера), необходимую для качественного и бесперебойного оказания Услуг.
|
||||
По истечении 10 (десяти) календарных дней с момента приостановления оказания услуг или расторжения договора безвозвратно удалить все элементы информационной системы Заказчика, предоставляемые Исполнителем согласно Спецификации. Уведомление о приостановлении оказания услуг направляется в сроки, указанные в разделе 8 настоящего Договора. Уведомление о предстоящем расторжении договора в одностороннем порядке направляется в порядке, указанном в п.15.3. настоящего Договора.
|
||||
В случае нарушения условий п. 9.2.11 Договора, а также при выявлении недостоверной информации, предоставленной Заказчиком Исполнитель вправе в одностороннем порядке расторгнуть Договор без возмещения Заказчику каких-либо затрат. Исполнитель не несет ответственности за убытки Заказчика, возникшие в связи с таким расторжением.
|
||||
## Егоров_0001 у Исполнителя в дополнение к предусмотренным в Спецификациях иные Инсталляционные, Абонентские (ежемесячные), Тарифицируемые и разовые (дополнительные) Услуги.
|
||||
Отказаться от ранее заказанных Тарифицируемых Услуг, письменно уведомив о своем намерении Исполнителя не позднее, чем за пять рабочих дней до даты прекращения указанных услуг. При этом Заказчик обязан оплатить Исполнителю стоимость фактически оказанных услуг.
|
||||
Запрашивать у Исполнителя информацию о ходе выполнения Услуг.
|
||||
Требовать сверки взаиморасчетов по Договору.
|
||||
Осуществлять иные права по настоящему Договору.
|
||||
Контролировать процесс оказания Исполнителем Услуг по настоящему Договору, не вмешиваясь в производственную и хозяйственную деятельность Исполнителя.
|
||||
## Соколов_0001 Заказчику Услуги в объеме и в сроки, предусмотренные настоящим Договором и Приложениями к нему.
|
||||
Принять в целях размещения на Технологической площадке Исполнителя оборудование Заказчика по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, и обеспечить условия его размещения и сохранности в соответствии с условиями Договора и Соглашения об уровне обслуживания.
|
||||
Обеспечить работоспособность оборудования и программного обеспечения ПАК Исполнителя на Технологической площадке Исполнителя в соответствии с гарантиями и сертификатами их производителей на основании Соглашения об уровне обслуживания.
|
||||
Вести учет потребления и оплаты Заказчиком Услуг, оказываемых в соответствии со Спецификацией.
|
||||
Рассматривать запросы Заказчика на Услуги, не предусмотренные Спецификацией к настоящему Договору, и предоставлять Заказчику в письменной форме технико-экономическое предложение на оказание запрашиваемых услуг или обоснованный отказ в связи с невозможностью оказания запрашиваемых услуг.
|
||||
В случае подачи Заказчиком запроса на вынос оборудования с Технологической площадки Исполнителя, передать по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон, принадлежащее Заказчику оборудование в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг.
|
||||
По окончании монтажа оборудования Заказчика на Технологической площадке провести подключение оборудования к сети электропитания; принять оборудование Заказчика в эксплуатацию. По факту передачи оборудования Заказчика в эксплуатацию уполномоченные представители Сторон подписывают Акт начала оказания Услуг.
|
||||
В случае предоставления Заказчиком Исполнителю учетной информации для получения доступа к услугам обеспечить конфиденциальность данной информации в соответствии со ст. 12 настоящего Договора.
|
||||
В случае возникновения аварий и их последствий на Технологической площадке обе Стороны составляют Акт, устанавливающий причины, виновника возникновения аварий, размеры и порядок возмещения ущерба.
|
||||
Обеспечивать представителям Заказчика возможность круглосуточного доступа к размещенному оборудованию Заказчика в соответствии с условиями настоящего Договора и Приложений к нему.
|
||||
|
||||
## Попов_0001 принимать и оплачивать Услуги Исполнителя в размере и в сроки, установленные настоящим Договором.
|
||||
Предоставлять в согласованные сроки Исполнителю по его требованию все необходимые для оказания им Услуг материалы, документы и сведения технического и организационного характера.
|
||||
За свой счет доставить оборудование Заказчика в предварительно согласованные Сторонами сроки на Технологическую площадку Исполнителя, передать Исполнителю оборудование в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон, и обеспечить необходимые условия проверки его комплектации и работоспособности Исполнителем.
|
||||
Информировать Исполнителя о вносимых в документацию по обслуживанию оборудования дополнениях и изменениях не позднее 1 (одного) рабочего дня со дня внесения таких дополнений и изменений.
|
||||
Соблюдать пропускной режим, правила посещения Технологической площадки, правила пожарной безопасности и техники безопасности, требования Госсанэпидемнадзора, а также отраслевых правил и норм, действующих в сфере деятельности Заказчика, в отношении Технологической площадки и предоставленного Исполнителем оборудования.
|
||||
Немедленно извещать Исполнителя о всяком повреждении, аварии, угрозе повреждения или аварии, или ином событии, которые могут нанести ущерб находящемуся на Технологической площадке оборудованию, и своевременно принимать все возможные меры по предотвращению дальнейшего разрушения или повреждения оборудования, находящегося на Технологической площадке, а также самой Технологической площадки.
|
||||
Не производить прокладок скрытых и открытых проводок и коммуникаций, перепланировок и переоборудования Технологической площадки, вызываемых потребностями Заказчика, без предварительного письменного разрешения Исполнителя и собственника здания. При наличии необходимости у Заказчика прокладки проводок и коммуникаций, перепланировок и переоборудования Технологической площадки и при наличии письменного разрешения Исполнителя и собственника здания, соответствующие работы выполняются за счет Заказчика. Затраты на проведение вышеуказанных работ не подлежат возмещению Исполнителем или собственником здания.
|
||||
В случае обнаружения Исполнителем самовольных перестроек, нарушения целостности стен, перегородок или перекрытий, переделок или прокладок сетей, проводок и иных коммуникаций на Технологической площадке, таковые должны быть ликвидированы Заказчиком, а Технологическая площадка приведена в прежний вид за его счет в срок, определяемый односторонним предписанием Исполнителя.
|
||||
Производить монтаж/демонтаж и подключение к сети или отключение от сети электропитания Технологической площадки Исполнителя любого оборудования Заказчика на Технологической площадке Исполнителя строго в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
|
||||
Не производить подключение любого оборудования к оборудованию Исполнителя и иному оборудованию, не принадлежащему Заказчику, без предварительного письменного разрешения Исполнителя.
|
||||
В случае предоставления Исполнителем на основании отдельно заключаемого Сторонами соглашения принадлежащих Исполнителю оборудования, программного обеспечения и/или технических средств в пользование Заказчику использовать их исключительно в соответствии с условиями такого соглашения и только для получения Услуг Исполнителя.
|
||||
После окончания срока, на который были предоставлены оборудование и/или технические средства, прекратить их использование и возвратить Исполнителю предоставленное им оборудование и/или технические средства в том состоянии, в котором Заказчик их получил с учетом нормального износа, а также осуществить иные действия, предусмотренные соглашением Сторон. После окончания срока, на который было предоставлено в пользование программное обеспечение Исполнителя, осуществить действия, предусмотренные соглашением Сторон, в отношении такого программного обеспечения.
|
||||
В случае неисполнения или ненадлежащего исполнения Заказчиком обязанностей, предусмотренных настоящим Договором и Приложениями к нему, по вывозу оборудования Заказчика с Технологической площадки, компенсировать расходы Исполнителя, связанные с демонтажем и хранением оборудования.
|
||||
Обеспечивать Исполнителю возможность доступа к размещенному оборудованию Заказчика в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
|
||||
В случае предоставления Исполнителем доступа к учетной информации обеспечить ее конфиденциальность в соответствии со ст. 12 настоящего Договора.
|
||||
В случае осуществления монтажа оборудования на Технологической площадке Заказчиком, по окончании монтажных работ сдать Исполнителю оборудование в эксплуатацию в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг. По факту передачи оборудования Заказчика в эксплуатацию уполномоченные представители Сторон подписывают Акт начала оказания Услуг.
|
||||
Осуществлять вынос (внос) с (на) Технологической площадки Исполнителя оборудования, принадлежащего Заказчику, в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон.
|
||||
Оказывать содействие в работе представителей Исполнителя включая участие специалистов и ответственных лиц Заказчика и/или его представителей в переговорах с представителями Исполнителя, согласование, утверждение и приемку результатов Услуг.
|
||||
Возместить Исполнителю все убытки, понесенные в результате использования услуг в целях, запрещенных настоящим Договором, включая перерасход энергоресурсов, износ оборудования и затраты на устранение иных последствий.
|
||||
# СОСТАВ, ПОРЯДОК И СРОКИ ОКАЗАНИЯ УСЛУГ
|
||||
Этапы оказания Услуг по Договору:
|
||||
Первый этап – оказание Инсталляционных услуг. Продолжительность оказания Инсталляционных услуг устанавливается в Спецификации.
|
||||
Второй этап - оказание Абонентских и Тарифицируемых услуг.
|
||||
Дата начала оказания Инсталляционных услуг определяется в Спецификации, но не ранее получения всей информации и документов от Заказчика, необходимых Исполнителю для начала предоставления Инсталляционных услуг по настоящему Договору.
|
||||
Исполнитель имеет право изменить (соразмерно продлить) срок предоставления Инсталляционных услуг в случае, если Заказчик не предоставил в согласованные сроки, указанные в настоящем Договоре документы и информацию. В этом случае Исполнитель направляет Заказчику письменное уведомление об изменении (соразмерном продлении) сроков предоставления Инсталляционных услуг.
|
||||
Планируемая дата начала оказания Абонентских услуг по Договору определяется в Спецификации. Фактическая дата начала оказания Абонентских услуг по Договору определяется датой подписания Акта начала оказания Услуг обеими Сторонами (по форме Приложения №5 к настоящему Договору). Заказчик признает и соглашается, что в случае несвоевременного оказания Исполнителем Инсталляционных услуг, которое произошло в связи с неисполнением, ненадлежащим исполнением обязательств Заказчика по Договору или по инициативе Заказчика, датой начала оказания Исполнителем Абонентских и Тарифицируемых услуг по настоящему Договору является планируемая дата начала оказания соответствующих Услуг, указанная в Спецификации. В этом случае Исполнитель выставляет счета Заказчику, начисляет платежи за соответствующие Услуги, а Заказчик обязуется оплачивать стоимость Услуг, с планируемой даты начала оказания Услуг. Исполнитель признает и соглашается с тем, что в случае готовности Заказчика к началу оказания услуг ранее планируемой даты начала оказания Услуг, указанной в Спецификации, Стороны вправе подписать Акт начала оказания услуг. В этом случае начисление платежей по Договору производится с даты подписания данного Акта.
|
||||
Если Заказчик планирует ввоз оборудования последовательно, то этап оказания Абонентских и Тарифицируемых услуг по Договору начинается с даты подписания первого Акта начала оказания услуг. Незаполненные оборудованием стойки оплачиваются Заказчиком, исходя из стоимости резерва места под стандартную стойку на территории Технологической площадки, указанной в Спецификации.
|
||||
Перечень и объем Услуг указывается в Спецификации.
|
||||
Схема размещения оборудования и технические требования указываются в Технических условиях (Приложение №4 к настоящему Договору).
|
||||
# СТОИМОСТЬ УСЛУГ
|
||||
Стоимость Инсталляционных, Абонентских (ежемесячных) и Тарифицируемых услуг указывается в Спецификации.
|
||||
Стоимость Услуг рассчитывается в соответствии со Спецификацией и запросами на оказание Тарифицируемых Услуг, поступившими от Заказчика в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
|
||||
Стоимость фактически оказанных Услуг рассчитывается Исполнителем исключительно на основании зафиксированных Исполнителем данных потребления Услуг, включая разовые (дополнительные) и Тарифицируемые услуги.
|
||||
# ПОРЯДОК И СРОКИ ОПЛАТЫ
|
||||
Все расчеты по настоящему Договору осуществляются в безналичной форме в рублях Российской Федерации. Датой оплаты считается дата зачисления денежных средств на расчетный счет Исполнителя. Факт оплаты Заказчик подтверждает копией платежного поручения с отметкой банка, высланной в адрес Исполнителя по факсу, электронным или иным способом.
|
||||
Расчеты между Сторонами по платежам, обусловленным доплатой или возвратом денежных средств, производятся на основании двустороннего Акта сверки расчетов и счета, выставляемого в срок не позднее 5 (пяти) рабочих дней с момента подписания Сторонами Акта сверки расчетов.
|
||||
Все платежи по настоящему Договору производятся Заказчиком в адрес Исполнителя по реквизитам, указанным в разделе 18 настоящего Договора, на основании выставляемых Исполнителем счетов не позднее 10 (десяти) дней с момента их выставления. Все расходы, связанные с переводом денежных средств на расчетный счет Исполнителя, принимает на себя Заказчик.
|
||||
Счета на оплату направляются по электронной почте в адрес Заказчика. Доставка оригинала УПД производится путем отправки курьерской или почтовой корреспонденции.
|
||||
Оплата за Инсталляционные услуги производится Заказчиком единовременно постоплатой в размере 100% (ста процентов) от стоимости этих Услуг на основании счета, выставляемого Исполнителем одновременно с УПД.
|
||||
Оплата за Абонентские услуги производится Заказчиком ежемесячно постоплатой в размере 100% (ста процентов) от стоимости Абонентских услуг на основании счета, выставляемого Исполнителем одновременно с УПД.
|
||||
Оплата Тарифицируемых услуг производится Заказчиком ежемесячно после окончания Отчетного периода на основании счета, выставляемого Исполнителем одновременно с УПД.
|
||||
В случае оказания Исполнителем разовых (дополнительных) услуг Заказчику их оплата производится на основании отдельного счета, выставленного на основании УПД. В данном УПД Исполнитель указывает перечень оказанных Услуг. Услуги предоставляются на основании письменного запроса Заказчика, оформленного в соответствии Регламентом взаимодействия Сторон в процессе оказания Услуг.
|
||||
В случае изменения стоимости Услуг согласно условиям настоящего Договора, ранее внесенная Заказчиком оплата за эти Услуги не пересчитывается по новой стоимости. В этом случае Услуги считаются оплаченными, и Исполнитель обязуется их предоставить в соответствии с условиями настоящего Договора.
|
||||
Стоимость Услуг, оказанных Исполнителем за неполный Отчетный период, рассчитывается Исполнителем в соответствии со Спецификацией пропорционально времени оказания Услуг.
|
||||
# ПОРЯДОК ВЗАИМОДЕЙСТВИЯ СТОРОН
|
||||
Порядок взаимодействия Сторон в процессе оказания Услуг, порядок и формы обмена информацией и документами между Сторонами определяется Регламентом взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору) и условиями настоящего Договора.
|
||||
# ПОРЯДОК СДАЧИ И ПРИЕМКИ УСЛУГ
|
||||
Сдача и приемка Абонентских и Тарифицируемых услуг производится ежемесячно на основании УПД.
|
||||
Сдача и приемка Инсталляционных услуг производится на основании УПД, подписываемого обеими Сторонами по окончании этапа инсталляции.
|
||||
Сдача и приемка разовых (дополнительных) услуг производится на основании УПД, подписываемого обеими Сторонами по окончании этапа оказания этих Услуг.
|
||||
По завершении каждого месяца оказания Услуг, по окончании этапа инсталляции, а также в иных случаях, предусмотренных Договором, в течение пяти рабочих дней Исполнитель направляет Заказчику для ознакомления и подписания два экземпляра, соответствующего УПД, датированные последним днем Этапа оказания Услуг.
|
||||
Заказчик в течение 5 (пяти) рабочих дней со дня выставления УПД обязан направить Исполнителю один экземпляр подписанного им УПД или письменный мотивированный отказ от приемки данного Этапа Услуг.
|
||||
В случае предъявления Заказчиком мотивированного отказа от приемки Услуг, Сторонами в течение 10 (десяти) календарных дней составляется двусторонний протокол с перечнем необходимых доработок и сроков их выполнения. Указанные доработки производятся силами и за счет Исполнителя. Повторная сдача-приемка Услуг производится Заказчиком в порядке, установленном настоящим Договором.
|
||||
В случае неисполнения Заказчиком условий, указанных в п.7.5 настоящего Договора, Услуги считаются оказанными в полном объеме и с надлежащим качеством, принятыми Заказчиком без претензий и подлежащими оплате, а УПД им подписанным.
|
||||
# ОСОБЫЕ УСЛОВИЯ
|
||||
## Исполнитель вправе приостановить оказание Услуг в следующих случаях:
|
||||
Просрочки Заказчиком платежей за оказанные Услуги на срок свыше десяти рабочих дней с момента выставления счета и УПД или в случае поступления оплаты не в полном объеме - до момента полной оплаты;
|
||||
При обнаружении неисправности, несоответствия оборудования Заказчика заявленным параметрам или при возникновении форс-мажорных обстоятельств, препятствующих качественному обслуживанию оборудования Заказчика до момента устранения соответствующих обстоятельств;
|
||||
При возникновении форс-мажорных обстоятельств, препятствующих качественному предоставлению Услуг Заказчику до момента устранения соответствующих обстоятельств;
|
||||
Если использование Заказчиком Услуг может нанести ущерб Исполнителю и/или вызвать сбой технических и программных средств Исполнителя и третьих лиц;
|
||||
Заказчик использует Услуги Исполнителя, либо Заказчик использует Технологическую площадку Исполнителя для каких-либо незаконных целей, в том числе, но не ограничиваясь этим, нарушает права на интеллектуальную собственность, исключительные права, патентные, авторские, смежные или иные охраняемые законом права Исполнителя и третьих лиц;
|
||||
Заказчик эксплуатирует предоставленное оборудование с нарушением правил технической эксплуатации;
|
||||
Наличия со стороны Заказчика любых противоправных действий, включая, но не ограничиваясь этим, получение, копирование, использование, отправление, опубликование, передача, воспроизведение, распространение любой охраняемой законодательством РФ информации, данных и сведений любым способом, а также любые действия, в том числе использование, копирование и распространение, в отношении программного обеспечения и/или других охраняемых объектов интеллектуальной собственности, материалов, информации, сведений, в том числе полученных посредством Услуг, если Заказчиком не было получено предварительное письменное согласие правообладателя на указанные действия, и которые нарушают авторские, исключительные или любые другие права правообладателя;
|
||||
Распространения Заказчиком и/или публикации любой информации, которая противоречит требованиям действующего законодательства Российской Федерации, положениям лицензий Министерства связи РФ (Министерства информационных технологий и связи в РФ), нормам международного права, и ущемляет права третьих лиц;
|
||||
Опубликования, копирования или распространения Заказчиком любой информации или программного обеспечения, которое содержит в себе коды компьютерных вирусов или других компонентов, приравненных к ним;
|
||||
Если Исполнитель получает соответствующее законное указание о прекращении оказания Услуг со стороны какого-либо государственного регулирующего или иного компетентного органа до момента отмены указания;
|
||||
Если Исполнитель получает указание со стороны ответственного представителя Заказчика о приостановлении оказания Услуг. Услуги приостанавливаются до момента, обозначенного в указании, либо до его отмены ответственным представителем Заказчика.
|
||||
## В случае возникновения событий, указанных в п.8.1.1, 8.1.2, 8.1.4, 8.1.11 настоящего Договора, Исполнитель письменно уведомляет Заказчика не позднее, чем за 5 (пять) Рабочих дней до приостановления Услуг с обоснованием причин приостановления оказания Услуг;
|
||||
В случае возникновения событий, указанных в п. 8.1.2., 8.1.5, 8.1.7, 8.1.8 настоящего Договора, Исполнитель одновременно с приостановкой оказания Услуг Заказчику письменно уведомляет Заказчика о необходимости устранения соответствующих нарушений. Заказчик обязуется в течение 24 часов после получения письменного уведомления Исполнителя устранить эти нарушения, если иной срок не указан в приложениях к настоящему договору. Исполнитель возобновляет оказание Услуг Заказчику только после устранения Заказчиком нарушений.
|
||||
В случае возникновения событий, указанных в п.п.8.1.10-8.1.11 настоящего Договора, Исполнитель в течение 5 (пяти) рабочих дней с даты приостановления оказания Услуг письменно уведомляет об этом Заказчика с обоснованием причин приостановления оказания Услуг и приложением подтверждающих это документов.
|
||||
## Время приостановления оказания Услуг по основаниям, указанным в п.8.1 Договора, не считается перерывом в оказании Услуг и не может рассматриваться как нарушение Исполнителем своих обязательств, предусмотренных настоящим Договором и Приложениями к нему.
|
||||
## Приостановление оказания Услуг и/или отключение программных или аппаратных средств Заказчика осуществляется до выполнения Заказчиком требований Исполнителя по устранению нарушений, указанных в п. 8.1. Приостановление оказания Услуг не отменяет выполнения Заказчиком всех своих обязательств по настоящему Договору, включая обязательство по ежемесячным платежам.
|
||||
## Заказчик обязуется компенсировать все документально обоснованные расходы и затраты, понесенные Исполнителем в результате приостановления и возобновления оказания Услуг, указанных в п.8.1.
|
||||
8.6. В случае приостановления оказания услуг или расторжения договора наличие и хранение резервных копии информационной системы (базы данных, программного обеспечения и пр. элементов) осуществляется силами и за счет Заказчика. Заказчик самостоятельно определяет перечень, обеспечивает сбор и перенос файлов для резервного хранения в течение 10 (десяти) календарных дней с момента получения уведомления о приостановлении услуг или расторжения договора.
|
||||
# ОТВЕТСТВЕННОСТЬ СТОРОН
|
||||
## Общие положения
|
||||
За невыполнение или ненадлежащее выполнение своих обязательств по настоящему Договору Стороны несут ответственность в соответствии с действующим законодательством РФ, настоящим Договором и Приложениями к настоящему Договору.
|
||||
Каждая из Сторон является единственно ответственной за соблюдение лицензионного законодательства в отношении используемых ее сотрудниками аппаратных и программных средств.
|
||||
Ни одна из Сторон ни при каких обстоятельствах не несет ответственности перед другой Стороной за «косвенные» убытки (упущенную выгоду), которые включают, но не ограничиваются, потерю дохода, прибыли, ожидаемой экономии, деловой активности или репутации.
|
||||
Заказчик и Исполнитель отвечают друг перед другом за правильность, достоверность и полноту передаваемых ими друг другу технических требований, документов, сведений и иной информации. В случае использования одной из Сторон недостоверной или неполной информации, полученной от другой Стороны, Сторона, получившая информацию, не несет ответственности за последствия, вызванные ее действиями на основании предоставленной недостоверной информации.
|
||||
В случае просрочки в оплате Услуг Исполнитель имеет право потребовать от Заказчика уплаты пени в размере 0,1% (ноль целых одной десятой) процента от неоплаченной суммы за каждый день просрочки, но не более 10% (десяти) процентов от просроченного платежа.
|
||||
В случае просрочки оказания любых Услуг, предусмотренных настоящим Договором, Заказчик имеет право потребовать от Исполнителя уплаты пени в размере 0,1% (ноль целых одной десятой) процента от не выполненных в срок Услуг за каждый день просрочки, но не более 10% (десяти) процентов от не выполненных в срок Услуг.
|
||||
Максимальный размер ответственности Исполнителя за ненадлежащее оказание Услуг по настоящему Договору устанавливается в размере суммы, фактически уплаченной Заказчиком за оказание соответствующих Услуг по Договору в течение Отчетного периода, в котором данное ненадлежащее исполнение допущено. Конкретная сумма, подлежащая уплате Заказчику, в случае нарушения условий предоставления Услуг Исполнителем определяется согласно схеме компенсации, указанной в пункте 2.3 Соглашения об уровне обслуживания (Приложение №2 к настоящему Договору).
|
||||
Заказчик имеет право на компенсацию в размере и порядке, установленном в пункте 2.3 Соглашения об уровне обслуживания.
|
||||
Уплата штрафных санкций не освобождает Стороны от выполнения обязательств по настоящему Договору.
|
||||
Каждая из Сторон Договора обязуется в течение срока действия настоящего Договора, а также в течение года с момента его расторжения/прекращения, воздерживаться от осуществления действий, направленных на переманивание работников другой Стороны.
|
||||
## Семёнов_0001 несет ответственность перед третьими лицами в случае предъявления любых претензий и исков такими третьими лицами, заключившими договоры с Заказчиком на оказание услуг, которые частично или полностью оказываются Заказчиком с помощью Услуг Исполнителя.
|
||||
Заказчик несет ответственность за соблюдение прав Исполнителя и третьих лиц на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав. В случае, если к Исполнителю по вине Заказчика будут предъявлены со стороны третьих лиц какие-либо претензии, вытекающие из нарушения Заказчиком прав на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав, Заказчик обязуется принять на себя эти претензии и возместить Исполнителю все убытки и расходы, понесенные Исполнителем в связи с нарушением таких прав, и за свой счет и на свой риск незамедлительно принять меры к урегулированию заявленных претензий.
|
||||
Заказчик полностью ответственен за сохранность своих учетных данных и за убытки или иной ущерб, которые могут возникнуть по причине несанкционированного использования этой информации. По факту утери, несанкционированного доступа к учетным данным или возможности возникновения такой ситуации Заказчик имеет право направить запрос Исполнителю на смену учетных данных. В этом случае Исполнитель по согласованию с Заказчиком осуществляет блокировку доступа к учетным данным Заказчика. Срок подобной блокировки согласовывается Сторонами дополнительно. При этом Исполнитель не несет ответственности за извещение любых третьих лиц о временной приостановке доступа Заказчика к его информационной системе и за возможные последствия, возникшие в результате отсутствия такого извещения.
|
||||
Заказчик несет ответственность за поддержание в актуальном состоянии списка его представителей, имеющих право обращаться в службу поддержки Исполнителя с запросами на изменение параметров Услуг.
|
||||
Заказчик несет ответственность за убытки, причиненные Исполнителю в результате повреждения и/или утраты оборудования Исполнителя и третьих лиц.
|
||||
Заказчик несет ответственность за поддержание в актуальном состоянии списка его представителей, допущенных на Технологическую площадку и к размещенному на ней оборудованию Заказчика.
|
||||
Заказчик несет ответственность за соответствие параметров своего размещаемого оборудования эксплуатационным требованиям и нормам, принятым на Технологической площадке Исполнителя. Любые изменения к требованиям и нормам на Технологической площадке Исполнителя должны быть сообщены Заказчику письменно не позднее, чем за тридцать календарных дней до их введения в действие.
|
||||
Заказчик обязуется возместить Исполнителю убытки, причиненные свойствами переданного оборудования, если Исполнитель, размещая оборудование Заказчика, не был письменно проинформирован об этих свойствах.
|
||||
На основании дополнительного письменного запроса Исполнитель предоставляет сотрудникам Заказчика, чьи фамилия, имя, отчество, должность и иные сведения, предоставленные Исполнителю по его требованию, указаны Заказчиком в запросе, допуск для обеспечения круглосуточного контролируемого физического доступа на Технологическую площадку в соответствии с Регламентом взаимодействия Сторон в процессе оказания услуг (Приложение №3 к настоящему Договору). Заказчик несет ответственность за безопасность и надлежащее использование пропусков на проход представителей Заказчика на территорию Технологической площадки. Пропуск не может быть передан третьим лицам. Исполнитель не несет ответственность за убытки, понесенные Заказчиком в результате потери пропусков и разглашения иных сведений по вине Заказчика.
|
||||
Заказчик несет ответственность за убытки, причиненные Исполнителю в результате повреждения и/или утраты оборудования Исполнителя и третьих лиц, разместивших свое оборудование на Технологической площадке, которые произошли в результате действий или бездействий представителей Заказчика и/или привлекаемой Заказчиком третьей стороны).
|
||||
Заказчик гарантирует, что не является участником деятельности, связанной с криптовалютным майнингом (включая, но не ограничиваясь, майнингом биткойнов, эфириума и иных криптовалют), а также не планирует использовать предоставляемые Исполнителем услуги, оборудование, вычислительные мощности, энергоресурсы или инфраструктуру центра обработки данных для осуществления майнинга или иных операций, связанных с генерацией криптовалют.
|
||||
Заказчик берет на себя обязательство:
|
||||
До заключения настоящего Договора письменно уведомить Исполнителя о любых обстоятельствах, прямо или косвенно связанных с криптовалютным майнингом, если такие обстоятельства могут повлиять на исполнение обязательств по Договору.
|
||||
Незамедлительно информировать Исполнителя о возникновении в течение срока действия Договора намерения использовать услуги в целях, указанных в п. 9.2.11 настоящего Договора.
|
||||
## Кузнецов_0001 не контролирует содержание информации, хранимой, публикуемой или распространяемой Заказчиком с использованием предоставленных Услуг, и не несет никакой ответственности за точность, качество и содержание такой информации.
|
||||
Исполнитель не несет ответственность за нарушение Заказчиком прав третьих лиц, включая, но не ограничиваясь этим, нарушение Заказчиком прав на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав, в том числе за нарушение, совершенное с использованием Услуг, предоставляемых Исполнителем.
|
||||
Исполнитель не несет ответственность за сбои в телекоммуникационных сетях, не находящихся под контролем Исполнителя в соответствии с Соглашением об уровне обслуживания.
|
||||
Исполнитель не несет ответственность за последствия изменений в размещаемом оборудовании и установленном на нем программном обеспечении, не согласованных с Исполнителем, и не гарантирует работоспособность ПАК Заказчика в случае внесения таких изменений Заказчиком.
|
||||
Исполнитель не является ответчиком или соответчиком по любым обязательствам и расходам, связанным с нарушением положений настоящего Договора Заказчиком или третьими лицами, использующими учетные данные Заказчика, а также связанным с использованием Заказчиком Интернет посредством Услуг Исполнителя; или связанным с помещением или передачей любого сообщения, информации, программного обеспечения или других материалов в сети Интернет Заказчиком или другими лицами, использующими его учетные данные.
|
||||
Исполнитель несет ответственность перед Заказчиком в размере, оговоренном в п.2.3. Соглашения об уровне обслуживания.
|
||||
Исполнитель несет ответственность перед Заказчиком в размере документально подтвержденного ущерба, причиненного Заказчику в результате поломки, утраты или повреждения размещаемого оборудования Заказчика, принятого в эксплуатацию по условиям настоящего Договора, в соответствии с п.9.4 настоящего Договора, а также невозможности работы оборудования ПАК Заказчика по вине Исполнителя в результате:
|
||||
Нарушения режимов электропитания;
|
||||
Нарушения допустимых параметров в помещении;
|
||||
Повреждений, нанесенных персоналом Исполнителя или представителями третьей стороны, привлекаемой Исполнителем для оказания Услуг по настоящему Договору в результате халатных и/или умышленных действий;
|
||||
Повреждений, нанесенных иными лицами, не являющимися представителями Заказчика и/или привлекаемой Заказчиком третьей стороны, допущенными на Технологическую площадку с получением доступа от Исполнителя, или проникших на Технологическую площадку в результате халатности Исполнителя и/или несоблюдении им Регламента взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
|
||||
Исполнитель не несет ответственность за работоспособность оборудования Заказчика, не принятого в эксплуатацию в соответствии с условиями настоящего Договора.
|
||||
Исполнитель не несет ответственность за утрату или повреждение оборудования Заказчика, в случаях, когда утрата или повреждение произошли из-за свойств оборудования Заказчика, о которых Исполнитель, размещая его на Технологической площадке, не был письменно (под роспись) предупрежден Заказчиком.
|
||||
Исполнитель несет ответственность в соответствии с п.9.4 настоящего Договора за повреждение оборудования Заказчика, произошедшее как в результате халатных и/или умышленных действий сотрудников Исполнителя и/или уполномоченных им третьих лиц, так и в результате нарушения Исполнителем технических условий и правил технической эксплуатации данного оборудования.
|
||||
## Ответственность сторон при утрате и повреждении оборудования
|
||||
Исполнитель несет ответственность при:
|
||||
утрате оборудования Заказчика в размере его рублевой балансовой стоимости, приведенной в Акте приема-передачи оборудования (Приложение №5 к настоящему Договору), с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС;
|
||||
повреждении оборудования – в размере документально подтвержденной стоимости восстановительного ремонта поврежденного оборудования, включая заменяемые в процессе ремонта поврежденные составляющие оборудования до состояния, соответствующего первоначальному. Стоимость подлежащих замене составляющих определяется на основании балансовой стоимости таких составляющих, указанных в Акте приема-передачи оборудования (Приложение №5 к настоящему Договору).
|
||||
Любая из Сторон в случае утраты или повреждения оборудования обязана немедленно (в течение суток с момента наступления такого случая) письменно известить об этом другую Сторону.
|
||||
В случае отсутствия спора Сторон о причинах утраты оборудования Исполнитель обязан в течение 15 (пятнадцати) рабочих дней с момента получения требования от Заказчика перечислить Заказчику рублевую балансовую стоимость оборудования, которая указана в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС, на счет Заказчика.
|
||||
В случае отсутствия спора Сторон о причинах повреждения оборудования Заказчика и признания Исполнителем своей ответственности за повреждение оборудования Заказчика сумма восстановительного ремонта определяется путем переговоров между Исполнителем и Заказчиком, результат которых оформляется в виде Акта о повреждении (утрате) оборудования Заказчика (форма акта в Приложении №5 к настоящему Договору), в котором фиксируется факт повреждения оборудования, время и причины повреждения оборудования, а также согласованная Сторонами стоимость его восстановительного ремонта. Исполнитель обязан в течение пяти рабочих дней с даты подписания обеими Сторонами Акта о повреждении (утрате) оборудования Заказчика перечислить Заказчику рублевую стоимость восстановительного ремонта оборудования, указанную в данном Акте, на счет Заказчика. В случае спора Сторон о стоимости восстановительного ремонта и отказа одной из Сторон от подписания Акта о повреждении (утрате) оборудования Стороны решают спор в порядке, предусмотренном условиями пп.9.4.5-9.4.7 настоящего Договора.
|
||||
В случае спора Сторон о причинах утраты или повреждения оборудования, а также о стоимости восстановительного ремонта, Заказчик имеет право проведения полной технической экспертизы технического состояния поврежденного оборудования. При этом техническая экспертиза должна быть проведена либо изготовителем данного оборудования, либо иной организацией, уполномоченной на осуществление данных действий изготовителем поврежденного оборудования, с участием привлеченной Исполнителем организации, компетентной и уполномоченной на проведение технической экспертизы такого оборудования. Этим же Актом технической экспертизы технического состояния поврежденного оборудования должна быть установлена стоимость его восстановительного ремонта, если таковой может быть произведен, и рублевая балансовая стоимость, приведенная в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС.
|
||||
В случае отказа Исполнителя от проведения технической экспертизы в течение 10 (десяти) рабочих дней с момента повреждения оборудования, Заказчик имеет право провести техническую экспертизу в одностороннем порядке. В данном случае Исполнитель обязан не препятствовать доступу специалистов, осуществляющих техническую экспертизу, к поврежденному оборудованию.
|
||||
Если по результатам технической экспертизы виновным за утрату или повреждение оборудования Заказчика будет признан Исполнитель, Заказчик обязан в течение 5 (пяти) рабочих дней с даты получения Акта технической экспертизы передать один экземпляр оригинала данного Акта Исполнителю. В случае согласия Исполнителя с результатами технической экспертизы он обязан в течение 5 (пяти) рабочих дней с даты получения им оригинала Акта технической экспертизы перечислить Заказчику рублевую стоимость:
|
||||
восстановительного ремонта оборудования, указанную в Акте технической экспертизы, в случае если такой ремонт может быть произведен, либо
|
||||
рублевую балансовую стоимость поврежденного оборудования, указанную в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС, в случае, если проведение восстановительного ремонта не представляется возможным, на счет Заказчика в соответствии с реквизитами, указанными в разделе 18 настоящего Договора. При этом Исполнитель обязан компенсировать Заказчику все документально подтвержденные затраты на проведение технической экспертизы.
|
||||
В случае несогласия Исполнителя с результатами технической экспертизы он обязан в течение пяти рабочих дней с даты получения Акта технической экспертизы письменно известить об этом Заказчика, давая основание Заказчику перенести данный спор на рассмотрение в суд.
|
||||
При судебном разбирательстве Заказчик вправе потребовать от Исполнителя не только стоимость поврежденного оборудования, но и компенсацию документально подтвержденных расходов по проведению технической экспертизы.
|
||||
Заказчик несет ответственность за нарушение работоспособности, утрату или повреждение оборудования, размещенного на Технологической площадке Исполнителя и не принадлежащего Заказчику, нанесенных представителями Заказчика и/или, привлекаемой Заказчиком третьей стороной, допущенными на Технологическую площадку с получением доступа от Исполнителя по запросу Заказчика в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
|
||||
За утрату или частичное повреждение размещаемого на Технологической площадке оборудования ПАК Заказчика после того, как наступила обязанность Заказчика вывезти оборудование, Исполнитель несет ответственность лишь при наличии с его стороны умысла или грубой неосторожности.
|
||||
Ответственность за порчу или утрату предоставленных Исполнителем в пользование Заказчику оборудования и технических средств несет Заказчик, кроме случаев, если будет доказана вина Исполнителя или привлекаемой им в процессе оказания Услуг третьей стороны. В случае возникновения спора между Заказчиком и Исполнителем о причинах утраты, повреждения оборудования и технических средств, а также стоимости восстановительного ремонта, Стороны вправе провести техническую экспертизу в порядке, предусмотренном пп. 9.4.5-9.4.7 настоящего Договора. Инициатором проведения экспертизы является Исполнитель. В случае признания вины Заказчика в повреждении или утрате оборудования или технических средств, Заказчик обязуется компенсировать Исполнителю документально подтвержденные затраты на проведение технической экспертизы в течение 5 (пяти) календарных дней с момента предоставления Исполнителем копий документов, подтверждающих понесенные на экспертизу затраты.
|
||||
В случае несогласия Заказчика с результатами технической экспертизы он обязан в течение пяти рабочих дней с даты получения Акта технической экспертизы письменно известить об этом Исполнителя, давая основание Исполнителю перенести данный спор на рассмотрение в суд.
|
||||
# ФОРС-МАЖОРНЫЕ ОБСТОЯТЕЛЬСТВА
|
||||
В случае возникновения форс-мажорных обстоятельств: стихийных бедствий, землетрясений, наводнений, пожаров, и других обстоятельств непреодолимой силы, Исполнитель и Заказчик освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по настоящему Договору. В течение трех календарных дней с момента наступления таких обстоятельств Сторона, пострадавшая от их влияния, письменно уведомляет другую Сторону о произошедших событиях.
|
||||
Сторона, понесшая в связи с обстоятельствами непреодолимой силы убытки из-за неисполнения или приостановления другой Стороной исполнения своих обязательств, может потребовать от Стороны, ставшей объектом действия непреодолимой силы, документ, подтверждающий масштабы произошедших событий, а также об их влиянии на деятельность пострадавшей Стороны.
|
||||
В случае возникновения форс-мажорных обстоятельств исполнение обязательств по настоящему Договору отодвигается на срок действия таких обстоятельств.
|
||||
В случае возникновения обстоятельств непреодолимой силы, продолжающихся более трех месяцев, Заказчик или Исполнитель вправе расторгнуть настоящий Договор после взаиморасчетов по фактически выполненным обязательствам.
|
||||
# РАЗРЕШЕНИЕ СПОРОВ
|
||||
Споры и разногласия, возникающие по настоящему Договору или в связи с ним, решаются Сторонами прежде всего путем переговоров или заключением Дополнительных соглашений. Срок ответа на претензию – 10 (десять) рабочих дней с момента получения.
|
||||
Все споры, разногласия или требования, возникающие из настоящего Договора или в связи с ним, в том числе касающиеся его исполнения, нарушения, прекращения или недействительности, подлежат разрешению в Арбитражном суде г. Москвы в соответствии с законодательством РФ.
|
||||
# КОНФИДЕНЦИАЛЬНОСТЬ
|
||||
Каждая из Сторон сохраняет в тайне всю Конфиденциальную информацию, раскрытую ей другой Стороной. Эта конфиденциальность распространяется, в том числе на любую конфиденциальную информацию, полученную Сторонами друг от друга устно, в письменном, электронном виде, а также в любой иной форме, в процессе оказания Услуг по настоящему Договору, и содержащую сведения об организационной структуре, документах и материалах по исполнению настоящего Договора, коммерческих аспектах настоящего Договора, о рыночных и технических планах развития и другие сведения, являющиеся исключительно Конфиденциальной информацией Сторон.
|
||||
Стороны будут ответственны за несанкционированное разглашение и использование Конфиденциальной информации. Каждая Сторона обязуется соблюдать в отношении Конфиденциальной информации столь же высокую степень осторожности, какую она соблюдает в отношении своей собственной Конфиденциальной информации аналогичной степени важности.
|
||||
Нарушением данного пункта не являются следующие случаи:
|
||||
разглашение информации, на которое получено письменное разрешение другой Стороны;
|
||||
разглашение информации по законному требованию компетентных государственных органов в соответствии с действующим законодательством РФ либо по решению компетентного суда;
|
||||
разглашение информации регулирующему органу, эксперту или арбитру, назначенному в соответствии с положениями настоящего Договора, в той степени, в которой такое разглашение обязательно по закону;
|
||||
разглашение информации финансовым или профессиональным советникам разглашающей Стороны при условии, что они берут на себя письменное обязательство по сохранению конфиденциальности данной информации.
|
||||
Стороны обязуются использовать предоставленную друг другу Конфиденциальную информацию исключительно в интересах выполнения обязательств по настоящему Договору, а не во вред другой Стороне.
|
||||
Конфиденциальность информации сохраняется обеими Сторонами после окончания срока действия или расторжения настоящего Договора в течение двадцати четырех календарных месяцев.
|
||||
# ПРАВА НА ИНТЕЛЛЕКТУАЛЬНУЮ СОБСТВЕННОСТЬ
|
||||
Ничто в настоящем Договоре не может быть интерпретировано как предоставление каких-либо прав на интеллектуальную собственность или как уступку прав на интеллектуальную собственность одной Стороны другой Стороне.
|
||||
Каждая Сторона самостоятельно несет ответственность за любые убытки, ущерб, или расходы, понесенные ею в связи с нарушением прав третьих лиц на интеллектуальную собственность в ходе исполнения настоящего Договора.
|
||||
# ПРОЧИЕ УСЛОВИЯ
|
||||
Подписывая настоящий Договор, Заказчик подтверждает, что у него есть доступ в интернет и возможность отправлять и принимать электронную почту.
|
||||
Передача любой из Сторон прав и обязанностей по настоящему Договору возможна только с предварительного письменного согласия другой Стороны, полученного не менее, чем за 30 (тридцать) календарных дней до даты передачи.
|
||||
Исполнитель имеет право соответствующим образом изменить стоимость Услуг по настоящему Договору, в случае если будут приняты и вступят в силу изменения в законодательстве Российской Федерации: а) устанавливающие дополнительные налоги, сборы и прочие обязательные платежи/отчисления в отношении Услуг, оказываемых Исполнителем по настоящему Договору; б) устанавливающие обязанность Исполнителя по приобретению дополнительного оборудования, лицензий, разрешений, сертификатов, необходимых для оказания Услуг по настоящему Договору.
|
||||
В случае если после заключения Договора будет официально опубликован (введен в действие) нормативный правовой акт, изменяющий ставку НДС и/или вводящий иные налоги и сборы, влияющие на стоимость Услуг, цена Договора подлежит пересчету с учетом вступивших в силу изменений.
|
||||
Исполнитель, руководствуясь ст. 431.2 ГК РФ, заверяет Заказчика на момент подписания настоящего Договора и гарантирует в налоговых периодах, в течении которых совершаются операции по настоящему Договору, что уплачивает все налоги и сборы в соответствии с действующим законодательством, а также ведет и своевременно подает в налоговые и иные государственные органы налоговую, статистическую и иную отчетность.
|
||||
Исполнитель обязуется по запросу Заказчика, составленному на основании требования / запроса налогового или иного государственного органа (с приложением копии такого требования/запроса или выдержки из такого требования/запроса), предоставить надлежащим образом заверенные копии документов, относящихся к исполнению настоящего Договора, в срок, не превышающий 5 (пять) рабочих дней с момента получения соответствующего запроса от Заказчика, либо в течение иного согласованного Заказчиком и Исполнителем срока.
|
||||
# СРОК ДЕЙСТВИЯ, ПОРЯДОК ИЗМЕНЕНИЯ И РАСТОРЖЕНИЯ ДОГОВОРА
|
||||
Договор вступает в силу с момента его подписания Сторонами. Начальный период действия настоящего Договора составляет 12 (двенадцать) календарных месяцев с даты его подписания Сторонами.
|
||||
Каждая из Сторон может прекратить действие настоящего Договора по истечении установленного Начального периода, представив об этом письменное уведомление не позднее, чем за 30 (тридцать) календарных дней до даты прекращения срока действия настоящего Договора. При отсутствии такового уведомления срок действия Договора ежегодно автоматически продлевается на следующие 12 (двенадцать) календарных месяцев. При этом действие Договора может быть прекращено по истечении каждого последующего годового периода письменным уведомлением не позднее, чем за 30 (тридцать) календарных дней до даты завершения годового периода.
|
||||
Любая из Сторон имеет право в одностороннем внесудебном порядке досрочно расторгнуть Договор, но не ранее, чем спустя тридцать календарных дней после письменного уведомления другой Стороны о своих намерениях. С момента получения письменного уведомления другой Стороной и до момента расторжения Договора никакие изменения, уменьшающие или увеличивающие совокупную величину ежемесячных платежей, в Договор внесены быть не могут.
|
||||
Если одна из Сторон допускает существенное нарушение настоящего Договора, которое не устраняется в течение 30 (тридцати) календарных дней после письменного требования об этом другой Стороны, то другая Сторона может немедленно расторгнуть Договор в одностороннем внесудебном порядке, направив нарушившей Стороне соответствующее письменное уведомление.
|
||||
Исполнитель имеет право в одностороннем внесудебном порядке расторгнуть или изменить Договор, не неся за это никакой ответственности, если, по не зависящим от него причинам, Исполнитель не получит какой-либо лицензии или разрешения, которые необходимы ему для оказания Услуг. Или, если они будут отозваны или иным образом отменены/изменены так, что это повлияет на способность Исполнителя оказывать Услуги.
|
||||
В случае просрочки платежей Заказчиком более чем на 30 (тридцать) рабочих дней, Исполнитель вправе в одностороннем внесудебном порядке расторгнуть Договор с направлением Заказчику письменного уведомления о расторжении не менее, чем за 10 (десять) рабочих дней до даты расторжения. Договор считается расторгнутым с даты, указанной в уведомлении о расторжении.
|
||||
Любые положения настоящего Договора могут быть изменены, отменены или прекращены по инициативе любой из Сторон. Изменения, отмена или прекращение действия положений Договора должны быть оформлены в письменном виде как Дополнительное соглашение к Договору, подписаны обеими Сторонами, и являются неотъемлемой частью настоящего Договора.
|
||||
Изменение требований к количеству и качеству предоставляемых Услуг, критериев и порядка оценки предоставляемых Услуг в обязательном порядке оформляются изменением существующих или разработкой новых Спецификаций.
|
||||
Изменение действующих Спецификаций или разработка новых происходит путем подписания Сторонами Дополнительных соглашений к Договору, после чего действие старых Спецификаций прекращается, и может быть инициирована любой из Сторон. Составление и оформление Спецификаций осуществляет Исполнитель.
|
||||
По окончании срока действия настоящего Договора отключение оборудования Заказчика от сетевой инфраструктуры Исполнителя и/или вывоз его с Технологической площадки осуществляется Сторонами в следующем порядке:
|
||||
Исполнитель не позднее двух календарных дней производит отключение оборудования Заказчика от сетевой инфраструктуры Исполнителя;
|
||||
демонтаж и вывоз оборудования Заказчика с Технологической площадки Исполнителя осуществляется Заказчиком самостоятельно в течение десяти календарных дней с даты окончания срока действия Договора, либо в иной согласованный Сторонами срок, оформленный в виде Дополнительного соглашения;
|
||||
отключение от электропитания и демонтаж стойки Заказчика по окончании срока действия настоящего Договора осуществляет Исполнитель, стоимость соответствующих услуг Исполнителя определяется, исходя из стоимости работ по деинсталляции инфраструктуры стойко-места, указанной в Спецификации. Заказчик обязуется подписать Дополнительное Соглашение на эти услуги и оплатить такие услуги Исполнителя в течение 5 (пять) рабочих дней с даты подписания Сторонами УПД и получения счета Исполнителя, но не позднее срока действия Договора;
|
||||
если Заказчик не оплатил стоимость Услуг Исполнителя по отключению от электропитания и демонтажу оборудования, или по истечении срока размещения оборудование Заказчика не было принято Заказчиком и не было вывезено Заказчиком в согласованный Сторонами срок, Исполнитель за свой счет складирует данное оборудование на условиях ответственного хранения на своей территории. В этом случае оборудование передается Заказчику только после оплаты Заказчиком расходов Исполнителя по отключению, демонтажу, складированию, хранению и приему-передаче оборудования. При этом стоимость Услуг Исполнителя по хранению оборудования определяется Дополнительном соглашении, исходя из расчета стоимости аренды склада на Технологической площадке Исполнителя. Заказчик обязуется оплатить такие услуги Исполнителя до момента передачи оборудования Заказчику. В момент возврата оборудования Кузнецов_0002 подписывается Акт приема-передачи оборудования с хранения в двух экземплярах.
|
||||
Никакое изменение, исправление, добавление или отмена любых положений настоящего Договора не вступает в силу, пока не будет согласовано в письменной форме уполномоченными представителями Исполнителя и Заказчика.
|
||||
Исполнитель вправе расторгнуть настоящий Договор в одностороннем порядке в случае отказа или уклонения Заказчика от подписания дополнительного соглашения об изменении цен по Договору (п.2.1.3) на условиях Исполнителя, направив Заказчику письменное уведомление не менее, чем за 10 (десять) рабочих дней до даты предполагаемого расторжения. В этом случае оплата оказанных Исполнителем услуг до даты расторжения осуществляется Заказчиком на прежних условиях.
|
||||
# ЗАКЛЮЧЕНИЕ ДОГОВОРА
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Договор и все Приложения, являющиеся его неотъемлемой частью, представляют собой полный Договор между Сторонами относительно изложенного в нем содержания.
|
||||
Договор и все Приложения к нему полностью исчерпывают договоренность между Сторонами по вопросу оказания Услуг и отменяют все другие соглашения и заявления, сделанные в устной или письменной форме до момента подписания настоящего Договора.
|
||||
Каждая Сторона признает, что ознакомилась с настоящим Договором, и что она надлежащим образом уполномочена подписать Договор, и согласна с его сроками и условиями.
|
||||
Стороны договорились, что переписка по электронной почте между уполномоченными лицами, указанными в Приложении № 3 к Договору, может рассматриваться ими в качестве доказательства, в случае возникновения спора.
|
||||
Документы, переданные по электронной почте, имеют юридическую силу оригинала и действуют до момента обмена Сторонами оригиналами документов.
|
||||
В целях оптимизации документооборота между Сторонами, а также повышения уровня сохранения и защиты передаваемых документов и информации, содержащейся в них, Стороны договорились о внедрении с даты подписания Договора системы электронного документооборота с использованием электронной подписи и передачей документов и информации через оператора электронного документа оборота (далее - «Оператор»).
|
||||
Стороны договорились осуществлять электронный документооборот следующими документами:
|
||||
# Счетами на оплату;
|
||||
# Актами сверки взаимных расчетов;
|
||||
# Протоколами разногласий к акту сверки взаимных расчетов;
|
||||
# Актами зачета взаимных требований;
|
||||
# Заявлениями о проведении зачета;
|
||||
# Электронными счетами фактурами (формат УПД(СЧФ);
|
||||
# Универсальными передаточными документами (формат УПД(СЧФДОП);
|
||||
# Актами об оказании услуг (акты оказанных услуг);
|
||||
# Актами выполненных работ (акты о передаче результатов работ);
|
||||
# Товарными накладными по форме ТОРГ-12;
|
||||
# Товарными накладными;
|
||||
# Приложениями к Договору;
|
||||
Документы считаются надлежаще полученными другой Стороной в случае:
|
||||
направления документов на бумажном носителе – в момент фактической доставки (выдачи) корреспонденции представителю получателя или проставления штемпеля почтового отделения получателя о прибытии корреспонденции в его адрес, если обязанности по получению им корреспонденции не были исполнены;
|
||||
направления документов по электронной почте уполномоченным лицам, указанным в Приложении № 3 к Договору – в рабочий день отправления сообщения с приложением документов, содержащих все надлежащие реквизиты, позволяющие установить, что документ исходит от стороны по договору и подписан уполномоченным лицом;
|
||||
направления документов через согласованного Сторонами оператора путем использования системы электронного документооборота (ЭДО) – в день направления документа и информации Оператору направившей его Стороны. Порядок использования Сторонами ЭДО определяется дополнительным соглашением к Договору.
|
||||
Договор заключен в двух экземплярах, обладающих равной юридической силой, по одному экземпляру для каждой Стороны.
|
||||
# ПЕРЕЧЕНЬ ПРИЛОЖЕНИЙ К ДОГОВОРУ
|
||||
# Договор включает следующие приложения, являющиеся его неотъемлемой частью:
|
||||
# Приложение №1. Спецификация и стоимость Услуг.
|
||||
# Приложение №2. Соглашение об уровне обслуживания.
|
||||
# Приложение №3. Регламент взаимодействия Сторон в процессе оказания Услуг.
|
||||
# Приложение №4. Технические условия.
|
||||
# Приложение №5. Акт начала оказания услуг
|
||||
|
||||
# ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН
|
||||
|
||||
|
||||
| г. Москва | 01.02.2026г. |
|
||||
|---|---|
|
||||
|
||||
|
||||
| Заказчик Меридиан_0001 Юридический адрес: Генеральный директор _______________ /ФИО/ М.П. | Исполнитель Альянс_0001 Юридический адрес: 115404, г. Москва, Вн. Тер. Г. Муниципальный округ Бирюлево Восточное, ул. 1-я Стекольная, д.7 стр. 2 Почтовый адрес: 115404, г. Москва, ул. 1-я Стекольная, д.7с2 ИНН_0001, КПП_0001 ОГРН_0001 РС_0001 Прогресс_0001, г. Москва КС_0001 БИК: 044525225 e-mail email_0001 Web-site www.nubes.ru Тел. +7_000_000_0001 Генеральный директор _______________ /В. С. Степаненко/ М.П. |
|
||||
|---|---|
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
**Дополнительное Соглашение Договор_0001**
|
||||
к Договору Договор_0003 от 20.12.2025
|
||||
|
||||
г. Москва «27» февраля 2026
|
||||
|
||||
**Альянс_0001** именуемое в дальнейшем "Заказчик", в лице Директора ФИО, действующего на основании Устава, и **ООО **"**НУБЕС**", именуемое в дальнейшем "Исполнитель", в лице Генерального директора Фёдоров_0001, действующего на основании Устава, с другой стороны, заключили настоящее Дополнительное соглашение Договор_0002 (далее - Соглашение) к Договору Договор_0005 от 29.12.2025 (далее - Договор) о нижеследующем:
|
||||
**1. ПРЕДМЕТ ДОПОЛНИТЕЛЬНОГО СОГЛАШЕНИЯ**
|
||||
Руководствуясь п. 15.7, п.15.9 Договора, Стороны договорились о нижеследующем:
|
||||
Изменить Спецификацию и стоимость Услуг (Приложение Договор_0002 к Договору). Утвердить новую Спецификацию и стоимость Услуг в соответствии с Приложением Договор_0002 к настоящему Соглашению.
|
||||
Датой начала оказания Услуг по Договору считать даты, указанные в Приложении № 1 к Соглашению.
|
||||
**ПРОЧИЕ УСЛОВИЯ**
|
||||
2.1. Во всём остальном, что не урегулировано настоящим Соглашением, действуют положения Договора.
|
||||
**СРОК ДЕЙСТВИЯ**
|
||||
Настоящее Соглашение подписано в двух экземплярах, обладающих равной юридической силой, по одному для каждой Стороны, вступает в силу с момента его подписания обеими Сторонами и является неотъемлемой частью Договора.
|
||||
**4.** **ПЕРЕЧЕНЬ ПРИЛОЖЕНИЙ К СОГЛАШЕНИЮ**
|
||||
Приложение № 1. Спецификация и стоимость услуг
|
||||
**ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН**
|
||||
|
||||
**Приложение Договор_0001**
|
||||
к Дополнительному Соглашению Договор_0002 от «27» февраля 2026
|
||||
к Договору Договор_0004 от 20.12.2025. на оказание технологических услуг
|
||||
|
||||
**Спецификация и стоимость услуг**
|
||||
### Состав и стоимость Услуг
|
||||
Разовые услуги
|
||||
Общая стоимость Инсталляционных услуг составляет **8 283,80 руб. **(восемь тысяч двести восемьдесят три рубля, 80 копеек) с учетом НДС 22%.
|
||||
|
||||
Абонентские услуги
|
||||
Общая стоимость Ежемесячных услуг составляет **137 880,96 руб**. (сто тридцать семь тысяч восемьсот восемьдесят рублей, 96 копеек) с учетом НДС 22%.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
| Заказчик Сфера_0001 Юридический адрес: Почтовый адрес: ИНН , КПП ОГРН Р/с Кор/сч БИК: Тел. , e-mail Директор _______________ФИО М.П. | Исполнитель Проект_0001 Юридический адрес: Новосибирск_0001 Почтовый адрес: Красноярск_0001 ИНН_0001, КПП_0001 ОГРН_0001 РС_0001 Меридиан_0001, г. Москва КС_0001 БИК: БИК_0001 e-mail email_0001 Web-site www.nubes.ru Тел. +7_000_000_0001 Генеральный директор _______________ / Волков_0001/ М.П. |
|
||||
|---|---|
|
||||
|
||||
|
||||
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Дата начала оказания услуг |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Организация L2 канала без резерва, в составе: Скорость порта: 1 Гбит/с - 1 шт. Период тарификации - месяц | 8 283,80 | 1 | 8 283,80 | 26.03.2026 |
|
||||
| Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | 8 283,80 | - |
|
||||
|
||||
|
||||
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Дата начала оказания услуг | Дата окончания оказания услуг |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 1 шт. Период тарификации - месяц | 1 525,00 | 1 | 1 525,00 | 26.03.2026 | |
|
||||
| 2 | Аренда публичной подсети (Длина префикса: /30 - 1 IPv4 адрес) | 589,66 | 1 | 589,66 | 26.03.2026 | |
|
||||
| 3 | Интернет без защиты от DDoS, в составе: Полоса Интернет: тариф от 5 до 100 Мбит/с - 100 Mбит/с Период тарификации - месяц | 7 015,00 | 1 | 7 015,00 | 26.03.2026 | |
|
||||
| 4 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 62 шт. Период тарификации - месяц Объем vRAM - 480 ГБайт Период тарификации - месяц Дисковое пространство: SATA - 720 ГБайт Период тарификации - месяц Дисковое пространство: Fast SSD - 6838 ГБайт Период тарификации - месяц | 123 823,00 | 1 | 123 823,00 | 26.03.2026 | |
|
||||
| 5 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 10 шт. Объем vRAM - 10 ГБайт Дисковое пространство: Fast SSD - 100 ГБайт | 4 928,30 | 1 | 4 928,30 | 29.01.2026 | |
|
||||
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 137 880,96 | - | |
|
||||
|
||||
|
||||
| Заказчик Директор Сфера_0001 _______________/ФИО/ М.П. | Исполнитель Генеральный директор Комплекс_0001 _______________ /Волков_0001/ М.П. |
|
||||
|---|---|
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
Приложение № 1
|
||||
к Договору на оказание технологических услуг № 03700_1 от 01.02.2026 г.
|
||||
|
||||
|
||||
**Спецификация и стоимость услуг**
|
||||
### Состав и стоимость Услуг
|
||||
|
||||
Абонентские услуги с 01.04.2026 – 24.04.2026
|
||||
Общая стоимость Ежемесячных услуг составляет **769 279,53 руб**. (семьсот шестьдесят девять тысяч двести семьдесят девять рублей, 53 копейки) с учетом НДС 22%.
|
||||
|
||||
|
||||
|
||||
Абонентские услуги с 25.04.2026
|
||||
Общая стоимость Ежемесячных услуг составляет **877 563,21 руб**. (восемьсот семьдесят семь тысяч пятьсот шестьдесят три рубля, 21 копейка) с учетом НДС 22%.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
| г. Москва | 01.02.2026г. |
|
||||
|---|---|
|
||||
|
||||
|
||||
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Аренда стойко-места, в составе: Номинальная мощность – 10 кВт на каждую стойку 2H1Q010 2H1K080 2H1I030 Размер стойки: 600х1200 - 3 шт. | 213 905,44 | 3 | 641 716,32 | 01.04.2026 |
|
||||
| 2 | Аренда PDU: 3Ф 32А вертикальный | 2 511,17 | 3 | 7 533,51 | 01.04.2026 |
|
||||
| 3 | Аренда серверного шкафа: 42Ux600x1200 | 2 440,00 | 3 | 7 320,00 | 01.04.2026 |
|
||||
| 4 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 3 шт. | 4 575,00 | 1 | 4 575,00 | 01.04.2026 |
|
||||
| 5 | Предоставление 1 ОВ в кабеле ВОЛС: НУБЕС-М9 От Красноярск_0001 До Нижний_Новгород_0001 | 18 300,00 | 4 | 73 200,00 | 01.04.2026 |
|
||||
| 6 | Аренда публичной подсети: Длина префикса: /29 - 5 IPv4 адресов | 884,50 | 1 | 884,50 | 01.04.2026 |
|
||||
| 7 | Next Generation Cloud: AMD 2.8 ГГц, в составе: Количество vCPU: гарантированная доля 80% - 32 шт. Объем vRAM - 96 ГБайт Дисковое пространство: SSD - 2000 ГБайт | 34 050,20 | 1 | 34 050,20 | 01.04.2026 |
|
||||
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 769 279,53 | - |
|
||||
|
||||
|
||||
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Аренда стойко-места, в составе: Номинальная мощность – 10 кВт на каждую стойку 2H1Q010 2H1K080 2H1I030 Размер стойки: 600х1200 - 3 шт. | 250 000,00 | 3 | 750 000,00 | 25.04.2026 |
|
||||
| 2 | Аренда PDU: 3Ф 32А вертикальный | 2 511,17 | 3 | 7 533,51 | 01.04.2026 |
|
||||
| 3 | Аренда серверного шкафа: 42Ux600x1200 | 2 440,00 | 3 | 7 320,00 | 01.04.2026 |
|
||||
| 4 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 3 шт. | 4 575,00 | 1 | 4 575,00 | 01.04.2026 |
|
||||
| 5 | Предоставление 1 ОВ в кабеле ВОЛС: НУБЕС-М9 От Красноярск_0001 До Нижний_Новгород_0001 | 18 300,00 | 4 | 73 200,00 | 01.04.2026 |
|
||||
| 6 | Аренда публичной подсети: Длина префикса: /29 - 5 IPv4 адресов | 884,50 | 1 | 884,50 | 01.04.2026 |
|
||||
| 7 | Next Generation Cloud: AMD 2.8 ГГц, в составе: Количество vCPU: гарантированная доля 80% - 32 шт. Объем vRAM - 96 ГБайт Дисковое пространство: SSD - 2000 ГБайт | 34 050,20 | 1 | 34 050,20 | 01.04.2026 |
|
||||
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 877 563,21 | - |
|
||||
|
||||
|
||||
| Заказчик Генеральный директор Прогресс_0001 ________________ /ФИО/ М.П. | Исполнитель Генеральный директор Вектор_0001 _______________ /В.С. Степаненко/ М.П. |
|
||||
|---|---|
|
||||
@@ -0,0 +1,29 @@
|
||||
Приложение № 1
|
||||
к Договору на оказание технологических услуг
|
||||
Договор_0001 от 20.12.2025 г.
|
||||
|
||||
|
||||
**Спецификация и стоимость услуг**
|
||||
### Состав и стоимость Услуг
|
||||
|
||||
Абонентские услуги
|
||||
Общая стоимость Абонентских услуг составляет **4 928,30 руб**. (четыре тысячи девятьсот двадцать восемь рублей, тридцать копеек) с учетом НДС 22% в месяц.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
| Екатеринбург_0001 | 20.12.2025г. |
|
||||
|---|---|
|
||||
|
||||
|
||||
| № п/п | Наименование Абонентских услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 10 шт. Период тарификации - месяц Объем vRAM - 10 ГБайт Период тарификации - месяц Дисковое пространство: Fast SSD - 100 ГБайт Период тарификации - месяц | 4 928,30 | 1 | 4 928,30 | 29.01.2026 |
|
||||
| Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | 4 928,30 | - |
|
||||
|
||||
|
||||
| Заказчик Директор Стандарт_0001 ________________ ФИО | Исполнитель Генеральный директор Комплекс_0001 _______________ Попов_0001 М.П. |
|
||||
|---|---|
|
||||
@@ -0,0 +1,28 @@
|
||||
Приложение № 1
|
||||
к Договору Договор_0001 от 01.02.2026 г.
|
||||
|
||||
|
||||
**Спецификация и стоимость услуг**
|
||||
### Состав и стоимость Услуг
|
||||
|
||||
Абонентские услуги
|
||||
Общая стоимость Ежемесячных услуг составляет **68 700,00 руб**. (шестьдесят восемь тысяч семьсот рублей(я), 0 копеек(a)) с учетом НДС 22% 12 388,52 (Двенадцать тысяч триста восемьдесят восемь рублей) 52 копейки.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
| г. Москва | 01.02.2026г. |
|
||||
|---|---|
|
||||
|
||||
|
||||
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Облачный диск Valo Cloud, в составе: Число пользователей - 300 шт. Период тарификации - месяц Дисковое пространство: Объем хранения - 6000 ГБайт Период тарификации - месяц | 68 700,00 | 1 | 68 700,00 | 01.02.2026 |
|
||||
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 68 700,00 | - |
|
||||
|
||||
|
||||
| Заказчик Генеральный директор Сервис_0001 ________________ ФИО. М.П. | Исполнитель Генеральный директор Меридиан_0001 _______________ Михайлов_0001 М.П. |
|
||||
|---|---|
|
||||
@@ -0,0 +1,324 @@
|
||||
# Архитектура 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые форматы файлов
|
||||
|
||||
| Формат | Обработка | Детали |
|
||||
|--------|-----------|--------|
|
||||
| **`.docx`** | → Markdown | Заголовки (H1–H3), **жирный**, *курсив*, таблицы (`python-docx`) |
|
||||
| **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования |
|
||||
| **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `�` |
|
||||
| **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB |
|
||||
| **`.doc`** (бинарный) | ❌ не поддерживается | Штурвал не даёт установить LibreOffice. Если понадобится — просить админов платформы |
|
||||
|
||||
Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`).
|
||||
|
||||
---
|
||||
|
||||
## Двухпроходная обфускация
|
||||
|
||||
### Проход 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())
|
||||
│ Scanner → Extractor → LLM NER → Obfuscator → Replacer → Builder
|
||||
▼
|
||||
bytes (ZIP-архив с обезличенными документами + mapping.csv)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Требования к Flask-приложению для Штурвала
|
||||
|
||||
### 1. `/health` — liveness probe
|
||||
|
||||
```python
|
||||
@health_bp.route("/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "version": "0.0.26"})
|
||||
```
|
||||
|
||||
**Обязательно.** Штурвал дёргает `/health` для readiness/liveness checks. Без этого под не поднимется. Ответ должен быть `200 OK`.
|
||||
|
||||
### 2. `no_cache` на все ответы
|
||||
|
||||
```python
|
||||
@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
|
||||
```
|
||||
|
||||
**Критично для SPA-подобного UI.** Без этого nginx/браузер кэшируют страницу → F5 показывает старую версию, кнопки не работают, версия не обновляется.
|
||||
|
||||
### 3. `X-Accel-Buffering: no` для SSE
|
||||
|
||||
```python
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
)
|
||||
```
|
||||
|
||||
**Критично для SSE-прогресса.** Без этого nginx буферизует поток → прогресс не отображается в реальном времени, все события приходят одним блоком в конце.
|
||||
|
||||
### 4. `MAX_CONTENT_LENGTH`
|
||||
|
||||
```python
|
||||
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
|
||||
```
|
||||
|
||||
Без этого Flask использует дефолтный лимит (~1MB) и отвергает docx/pdf.
|
||||
|
||||
### 5. `GeneratorExit` в SSE-генераторе
|
||||
|
||||
```python
|
||||
try:
|
||||
yield f"event: start\n..."
|
||||
except GeneratorExit:
|
||||
return # клиент отключился — не продолжаем обработку
|
||||
```
|
||||
|
||||
**Критично.** При закрытии вкладки браузер рвёт SSE-соединение. Без `GeneratorExit` генератор продолжает слать запросы в LLM, тратя токены впустую.
|
||||
|
||||
### 6. `stream_with_context`
|
||||
|
||||
```python
|
||||
from flask import stream_with_context
|
||||
|
||||
return Response(stream_with_context(generate()), ...)
|
||||
```
|
||||
|
||||
**Обязательно для SSE.** Без этого Flask держит приложение заблокированным на время SSE-соединения, другие запросы не обрабатываются.
|
||||
|
||||
### 7. `sys.path` для импорта пакета drhider
|
||||
|
||||
```python
|
||||
_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
```
|
||||
|
||||
Штурвал запускает из `site/`, пакет `drhider/` на уровень выше. Без этого импорт не работает.
|
||||
|
||||
### 8. In-memory session storage
|
||||
|
||||
Сессии хранятся в `site/session.py` как `Dict[str, dict]`. **Не персистентны** — теряются при рестарте пода. Очистка по таймауту (2 часа).
|
||||
|
||||
### 9. LLM-клиент через переменные окружения
|
||||
|
||||
```python
|
||||
LLM_KEY # API ключ
|
||||
LLM_URL # эндпоинт (по умолчанию https://api.aillm.ru/v1/chat/completions)
|
||||
LLM_MODEL # модель (по умолчанию gpt-oss-120b)
|
||||
```
|
||||
|
||||
### 10. BOM в CSV
|
||||
|
||||
```python
|
||||
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
|
||||
```
|
||||
|
||||
BOM (`\ufeff`) нужен для корректного открытия CSV в Excel (чтобы русские буквы не ломались).
|
||||
│
|
||||
▼
|
||||
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-архив
|
||||
```
|
||||
|
||||
### Имена скачиваемых файлов
|
||||
|
||||
Формат: `drhider_YYYY-MM-DD_HH-MM-SS.zip` и `mapping_YYYY-MM-DD_HH-MM-SS.csv`
|
||||
|
||||
Время — московское (UTC+3). Генерируется на сервере в момент запроса:
|
||||
|
||||
```python
|
||||
# api_bp.py — download() / csv_download()
|
||||
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
download_name=f"drhider_{ts}.zip" # → Content-Disposition
|
||||
download_name=f"mapping_{ts}.csv" # → Content-Disposition
|
||||
```
|
||||
|
||||
Фронтенд (JS) читает имя из заголовка `Content-Disposition`, **не** хардкодит.
|
||||
|
||||
---
|
||||
|
||||
## Принципы
|
||||
|
||||
1. **Модули — чистые функции.** Где возможно — без классов, без состояния.
|
||||
2. **Единственный класс — TwoPassObfuscator.** Только он управляет состоянием (mapping, sorted_keys).
|
||||
3. **Flask — тонкая прослойка.** Только принимает запрос, вызывает `obfuscate_files()`, отдаёт ответ.
|
||||
4. **Без БД.** Всё в памяти. Никаких внешних зависимостей кроме LLM API.
|
||||
5. **⛔ Ноль внешних зависимостей на фронтенде.** Никаких CDN, внешних шрифтов, иконок, JS-библиотек. ВСЁ в коде. Страница грузится за 1 запрос.
|
||||
6. **⛔ ВСЯ обработка данных — на бэкенде.** Фронтенд только отображает и скачивает. Никакой JS-логики для ZIP, парсинга, обфускации. Один запрос → один ответ.
|
||||
7. **⛔ Пофайловая обработка — на бэкенде.** Файлы обрабатываются строго последовательно (по одному), без параллельных LLM-запросов. Прогресс — бэкенд сообщает статус каждого файла.
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# Деплой, ВМ и переменные окружения
|
||||
|
||||
---
|
||||
|
||||
## Платформа
|
||||
|
||||
**Штурвал** — 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes-кластер (iot-naeel)
|
||||
|
||||
DrHider работает в кластере `iot-naeel` на платформе Штурвал.
|
||||
|
||||
### Доступ к kubectl
|
||||
|
||||
kubectl настроен на ВМ `5.172.178.213`. Все команды выполняются через SSH:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 kubectl ...
|
||||
```
|
||||
|
||||
### DrHider
|
||||
|
||||
```bash
|
||||
# Найти под drhider (namespace — UUID Штурвала)
|
||||
kubectl get pods --all-namespaces | grep pythonk8s
|
||||
|
||||
# Логи
|
||||
kubectl logs -n <namespace> <pod>
|
||||
|
||||
# Перезапуск — через UI Штурвала (Redeploy)
|
||||
```
|
||||
|
||||
### Ingress
|
||||
|
||||
```bash
|
||||
# Реплики
|
||||
kubectl get deploy -n ingress shturval-ingress-controller-controller
|
||||
|
||||
# externalTrafficPolicy
|
||||
kubectl get svc -n ingress shturval-ingress-controller-controller \
|
||||
-o jsonpath='{.spec.externalTrafficPolicy}'
|
||||
|
||||
# Масштабирование (если сбросилось)
|
||||
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
|
||||
```
|
||||
|
||||
### Cilium / MTU
|
||||
|
||||
```bash
|
||||
# Текущий MTU
|
||||
kubectl get configmap -n kube-system cilium-config -o jsonpath='{.data.mtu}'
|
||||
|
||||
# Статус Cilium
|
||||
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
|
||||
```
|
||||
|
||||
### VIP (kube-vip)
|
||||
|
||||
VIP кластера: `185.247.187.151`. Раздаётся через kube-vip (ARP) на все 4 ноды.
|
||||
|
||||
---
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
```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,134 @@
|
||||
# Варианты решения ingress replicas + TCP stall
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
**Источники:** Sonnet analysis, Opus analysis
|
||||
**Связано:** [KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md](KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md)
|
||||
|
||||
---
|
||||
|
||||
## Ключевой вывод (оба согласны)
|
||||
|
||||
Проблемы **две независимые**, решать по отдельности:
|
||||
|
||||
1. **TCP stall 51с** — PMTU Blackhole на Geneve-туннеле, а не свойство `Cluster`-политики. `Local` лечит как побочный эффект (убирает cross-node).
|
||||
2. **Сброс реплик** — `kubectl scale` обходит Helm, поэтому реконсиляция откатывает.
|
||||
|
||||
> ⚠️ **Важно (Opus):** `kubectl scale` вы обходите Helm, поэтому и сбрасывается. Любое итоговое решение должно быть в **values чарта**, а не через `kubectl`.
|
||||
|
||||
---
|
||||
|
||||
## Варианты
|
||||
|
||||
### 1. DaemonSet (рекомендуется)
|
||||
|
||||
Одно изменение в Helm values:
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
kind: DaemonSet
|
||||
# replicaCount игнорируется
|
||||
```
|
||||
|
||||
- ✅ Автоматически 1 под/ноду
|
||||
- ✅ Helm не ломает
|
||||
- ✅ Стандарт для bare-metal / kube-vip
|
||||
- ✅ Не требует менять сетевую конфигурацию
|
||||
|
||||
### 2. Починить MTU (убрать stall в корне)
|
||||
|
||||
51с — это классический Path MTU Discovery Blackhole. Geneve: MTU 1500 → ~1450.
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
ip link show | grep -E "cilium|genev"
|
||||
iptables -t mangle -L FORWARD -n -v | grep TCPMSS
|
||||
```
|
||||
|
||||
**Исправление:**
|
||||
```bash
|
||||
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
```
|
||||
|
||||
Если stall устранён → можно вернуть `externalTrafficPolicy: Cluster` и replicaCount 2.
|
||||
|
||||
### 3. Cilium DSR (Direct Server Return)
|
||||
|
||||
Ответный трафик идёт напрямую от пода к клиенту, без cross-node SNAT.
|
||||
|
||||
```yaml
|
||||
# cilium-config (требует kube-proxy replacement = strict)
|
||||
loadBalancer:
|
||||
mode: dsr
|
||||
kubeProxyReplacement: strict
|
||||
```
|
||||
|
||||
- ❌ Долго, меняет всю сетевую конфигурацию
|
||||
|
||||
### 4. replicaCount: 4 + podAntiAffinity
|
||||
|
||||
Если Штурвал не даёт сменить `kind` на DaemonSet:
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
replicaCount: 4
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
- ✅ Гарантирует 1 под/ноду
|
||||
- ✅ Helm values зафиксированы, не сбрасывается
|
||||
|
||||
---
|
||||
|
||||
## Рекомендуемый путь (Sonnet + Opus)
|
||||
|
||||
| Приоритет | Действие | Эффект |
|
||||
|-----------|----------|--------|
|
||||
| 🟢 Сразу | `controller.kind: DaemonSet` в Helm values | Убирает костыль с репликами навсегда |
|
||||
| 🟡 Параллельно | Диагностика MTU + MSS clamping | Устраняет stall в корне |
|
||||
| 🔵 Потом | Если MTU починен — вернуть `Cluster` | Можно уйти от `Local` и требования под/ноду |
|
||||
|
||||
## Как передать девопсу (Opus)
|
||||
|
||||
Для чарта `shturval-ingress-controller-2.12.1`:
|
||||
|
||||
```bash
|
||||
# Вариант A: DaemonSet
|
||||
helm upgrade -n ingress shturval-ingress-controller <чарт> \
|
||||
--set controller.kind=DaemonSet
|
||||
|
||||
# Вариант B: replicaCount + antiAffinity (если DaemonSet недоступен)
|
||||
helm upgrade -n ingress shturval-ingress-controller <чарт> \
|
||||
--set controller.replicaCount=4 \
|
||||
--set controller.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].labelSelector.matchLabels.app\.kubernetes\.io/name=ingress-nginx \
|
||||
--set controller.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].topologyKey=kubernetes.io/hostname
|
||||
```
|
||||
|
||||
Либо через Штурвал UI — изменить `replicaCount: 4` в настройках чарта.
|
||||
|
||||
## Диагностика MTU (Opus)
|
||||
|
||||
```bash
|
||||
# реальный MTU туннеля
|
||||
ip link show | grep -E "cilium|genev"
|
||||
|
||||
# есть ли MSS clamping
|
||||
iptables -t mangle -L FORWARD -n -v | grep MSS
|
||||
```
|
||||
|
||||
Варианты фикса:
|
||||
- MSS clamping: `iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu`
|
||||
- Cilium DSR: `loadBalancer.mode: dsr` + `kubeProxyReplacement: strict`
|
||||
- Выставить MTU в Cilium под физическую сеть (Geneve overhead ~50 байт)
|
||||
|
||||
---
|
||||
|
||||
## Текущий статус
|
||||
|
||||
`externalTrafficPolicy: Local` — не сбрасывается.
|
||||
`replicas: 4` — ручной scale, периодически сбрасывается Helm на 2.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Kube-vip + externalTrafficPolicy: Local + ingress replicas
|
||||
|
||||
**Дата:** 2026-07-13
|
||||
**Кластер:** iot-naeel
|
||||
**Сервис:** `ingress/shturval-ingress-controller-controller`
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
`externalTrafficPolicy: Local` на ingress-сервисе + 2 ingress-пода на 4 нодах.
|
||||
|
||||
Kube-vip раздаёт VIP (`185.247.187.151`) на **все 4 ноды** через ARP.
|
||||
С `Local` трафик, пришедший на ноду БЕЗ ingress-пода — **дропается**.
|
||||
|
||||
Результат: 50% запросов → `ERR_TIMED_OUT`.
|
||||
|
||||
## Почему не было раньше
|
||||
|
||||
Раньше стоял `externalTrafficPolicy: Cluster` — kube-vip форвардил трафик
|
||||
на любую ноду с ingress через Geneve-туннель. F5 работал, но при передаче
|
||||
**больших файлов** возникал TCP stall на 51 секунду (cross-node Geneve).
|
||||
|
||||
## Хронология
|
||||
|
||||
1. `Cluster` → всё работало, но большие файлы висли на 51с
|
||||
2. `Cluster` → `Local` — файлы перестали виснуть, но F5 стал дропаться (реплик 2)
|
||||
3. `kubectl scale --replicas=4` — обе проблемы решены
|
||||
4. **2026-07-13 10:24 MSK** — Helm-релиз ревизия 6 → `replicas` сброшены с 4 на 2
|
||||
5. `kubectl scale --replicas=4` — снова 4
|
||||
|
||||
## Почему сбрасывается
|
||||
|
||||
Ingress управляется Helm-чартом `shturval-ingress-controller-2.12.1`.
|
||||
|
||||
```yaml
|
||||
# helm get values -n ingress shturval-ingress-controller
|
||||
controller:
|
||||
replicaCount: 2 # ← вот причина
|
||||
```
|
||||
|
||||
Штурвал периодически реконсилит деплой под Helm-значения —
|
||||
видит 4 → возвращает на 2.
|
||||
|
||||
`externalTrafficPolicy: Local` при этом **не сбрасывается** (ставилось через `kubectl patch svc`, не через Helm).
|
||||
|
||||
## Что нужно от девопса
|
||||
|
||||
**Единственное изменение:** `replicaCount: 2 → 4` в Helm-чарте ingress.
|
||||
|
||||
```
|
||||
helm upgrade -n ingress shturval-ingress-controller <чарт> --set controller.replicaCount=4
|
||||
```
|
||||
|
||||
Или DaemonSet.
|
||||
|
||||
## Текущее состояние (исправлено вручную, будет сброшено)
|
||||
|
||||
```bash
|
||||
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
|
||||
```
|
||||
|
||||
| Под | Нода |
|
||||
|-----|------|
|
||||
| `j4z6z` | iot-naeel-control-plane-xb699 |
|
||||
| `q8v8g` | iot-naeel-workers-vqphm-6f74n |
|
||||
| `zqxqg` | iot-naeel-workers-vqphm-bhbvs |
|
||||
| `fp4p2` | iot-naeel-workers-vqphm-v8zq4 |
|
||||
|
||||
## Результат
|
||||
|
||||
10 последовательных запросов: 10/10 HTTP 200, 130-250ms.
|
||||
|
||||
## Что нужно от девопса
|
||||
|
||||
1. Перевести ingress на **DaemonSet** — автоматически по поду на ноду
|
||||
2. Или `replicas = max(2, количество нод)` — автоскейлинг при добавлении нод
|
||||
3. При передеплое Штурвала `replicas` не должен сбрасываться на 2
|
||||
|
||||
## Ноды (4)
|
||||
|
||||
| Нода | Тип |
|
||||
|------|-----|
|
||||
| iot-naeel-control-plane-xb699 | control-plane |
|
||||
| iot-naeel-workers-vqphm-6f74n | workers |
|
||||
| iot-naeel-workers-vqphm-bhbvs | workers |
|
||||
| iot-naeel-workers-vqphm-v8zq4 | workers |
|
||||
@@ -0,0 +1,288 @@
|
||||
# Инструкция: перенос UI и загрузки файлов с ВМ на Flask (Штурвал)
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
**Основано на:** drhider v0.0.26 — проверено в бою
|
||||
|
||||
---
|
||||
|
||||
## Архитектура загрузки (drhider как образец)
|
||||
|
||||
```
|
||||
Браузер (index.html)
|
||||
│ JS: uploadFiles() — цикл по файлам
|
||||
│ XHR POST /api/upload (FormData, по одному файлу)
|
||||
▼
|
||||
Flask (api_bp.py)
|
||||
│ upload(): сохраняет в сессию → {session_id}
|
||||
│ process_stream(): SSE — обработка с прогрессом
|
||||
▼
|
||||
Python (drhider/*.py)
|
||||
│ obfuscate_files() — основная логика
|
||||
▼
|
||||
Flask → ZIP + CSV → браузеру
|
||||
```
|
||||
|
||||
## Файлы, отвечающие за UI и загрузку
|
||||
|
||||
### 1. `site/templates/index.html` — весь фронтенд
|
||||
|
||||
**Ключевые элементы:**
|
||||
|
||||
```html
|
||||
<!-- Файловый инпут — множественный выбор -->
|
||||
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.zip">
|
||||
|
||||
<!-- Таблица выбранных файлов -->
|
||||
<table>
|
||||
<tbody id="fileList"></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Статус-бар -->
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<!-- Кнопки скачивания (скрыты до готовности) -->
|
||||
<div class="dl-btns" id="dlBtns">
|
||||
<button onclick="downloadZip()">📦 Скачать ZIP</button>
|
||||
<button onclick="downloadCsv()">📋 Скачать CSV</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Ключевые JS-функции:**
|
||||
|
||||
| Функция | Что делает |
|
||||
|---------|------------|
|
||||
| `resetAll()` | Сброс всего состояния (F5, между загрузками) |
|
||||
| `rr()` | Перерисовка таблицы файлов |
|
||||
| `uploadFiles()` | **Главная** — цикл загрузки + SSE-обработка |
|
||||
| `ss(idx, html)` | Обновление ячейки статуса в таблице |
|
||||
| `downloadZip()` / `downloadCsv()` | Скачивание результатов |
|
||||
|
||||
**Критичные исправления (v0.0.26 — v0.0.29):**
|
||||
|
||||
```javascript
|
||||
// 1. Прогрев upstream при загрузке страницы — обязательно!
|
||||
window.addEventListener('load', () => {
|
||||
sf = []; fi.value = ''; rr();
|
||||
fetch('/health').catch(() => {}); // ← обязательно! греет upstream
|
||||
});
|
||||
|
||||
// 2. Abort предыдущих запросов перед новым
|
||||
let activeES = null; // активный EventSource
|
||||
let activeXHR = null; // активный XHR
|
||||
|
||||
// 3. Защита от F5 во время загрузки
|
||||
window.addEventListener('beforeunload', () => resetAll());
|
||||
|
||||
// 4. Сохранение списка файлов до очистки
|
||||
const files = sf.slice(); // копия перед resetAll()
|
||||
|
||||
// 5. Выбор файлов — ДОБАВЛЯЕТ в таблицу (не стирает, с дедупом)
|
||||
fi.addEventListener('change', () => {
|
||||
const incoming = Array.from(fi.files);
|
||||
const seen = new Set(sf.map(f => f.name + '|' + f.size));
|
||||
for (const f of incoming) {
|
||||
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
|
||||
}
|
||||
const d = new DataTransfer();
|
||||
sf.forEach(f => d.items.add(f));
|
||||
fi.files = d.files;
|
||||
rr();
|
||||
});
|
||||
|
||||
// 6. Цикл загрузки (fetch, не XHR — XHR давал RST на первом запросе)
|
||||
for (let i = 0; i < total; i++) {
|
||||
const fd = new FormData();
|
||||
fd.append('files', f, f.name);
|
||||
if (currentSid) fd.append('session', currentSid);
|
||||
const resp = await fetch('/api/upload?_=' + Date.now(), { // ← fetch, не XHR
|
||||
method: 'POST', body: fd
|
||||
});
|
||||
const data = await resp.json();
|
||||
currentSid = data.session;
|
||||
}
|
||||
|
||||
// 7. SSE-обработка
|
||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||
activeES.addEventListener('start', ...); // файл начат
|
||||
activeES.addEventListener('done', ...); // файл готов
|
||||
activeES.addEventListener('complete', ...); // всё готово
|
||||
|
||||
// 8. Скачивание ZIP/CSV — через fetch+Blob (не window.location!)
|
||||
function downloadZip() {
|
||||
if (!currentSid) return;
|
||||
fetch('/api/download/' + currentSid)
|
||||
.then(r => {
|
||||
const disp = r.headers.get('Content-Disposition');
|
||||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||||
return Promise.all([r.blob(), m ? m[1] : 'result.zip']);
|
||||
})
|
||||
.then(([blob, fname]) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `site/routes/api_bp.py` — API эндпоинты
|
||||
|
||||
```
|
||||
POST /api/upload — загрузка одного файла → {session_id}
|
||||
GET /api/process_stream/<sid> — SSE: обработка с прогрессом
|
||||
POST /api/process/<sid> — обработка без SSE (legacy)
|
||||
GET /api/download/<sid> — скачать ZIP
|
||||
GET /api/csv/<sid> — скачать CSV отдельно
|
||||
```
|
||||
|
||||
**Критичные заголовки для SSE:**
|
||||
|
||||
```python
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no" # ← без этого nginx буферизует SSE
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Критично: GeneratorExit в SSE-генераторе:**
|
||||
|
||||
```python
|
||||
def generate():
|
||||
for idx, (fname, content) in enumerate(files):
|
||||
try:
|
||||
yield f"event: start\ndata: ...\n\n"
|
||||
except GeneratorExit:
|
||||
return # клиент отключился — не обрабатываем дальше
|
||||
|
||||
# ... обработка файла ...
|
||||
|
||||
try:
|
||||
yield f"event: done\ndata: ...\n\n"
|
||||
except GeneratorExit:
|
||||
return
|
||||
```
|
||||
|
||||
### 3. `site/routes/health_bp.py` — liveness probe
|
||||
|
||||
```python
|
||||
@health_bp.route("/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "version": "0.0.1"})
|
||||
```
|
||||
|
||||
**Обязательно для Штурвала.**
|
||||
|
||||
### 4. `site/app.py` — точка входа
|
||||
|
||||
**Критичные настройки:**
|
||||
|
||||
```python
|
||||
VERSION = "0.0.1"
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
from routes import register_routes
|
||||
register_routes(app)
|
||||
|
||||
@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()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Пошаговая инструкция миграции
|
||||
|
||||
### Шаг 1: Скопировать скелет UI
|
||||
|
||||
Скопировать `site/templates/index.html` из drhider как основу.
|
||||
Заменить:
|
||||
- Заголовок (`<title>`, `.title`, `.card-header`)
|
||||
- Текст описания
|
||||
- `accept` в `<input type="file">` если другие форматы
|
||||
- Логику в `uploadFiles()` — вызов своего API вместо `obfuscate_files`
|
||||
|
||||
### Шаг 2: Адаптировать API
|
||||
|
||||
Скопировать `site/routes/api_bp.py`, заменить:
|
||||
- Название blueprint'а
|
||||
- Логику в `process_stream()` — вызов своей функции вместо `obfuscate_files()`
|
||||
- Формат SSE-событий если нужен другой
|
||||
|
||||
### Шаг 3: Health endpoint
|
||||
|
||||
Скопировать `site/routes/health_bp.py` как есть. Только версию поменять.
|
||||
|
||||
### Шаг 4: app.py
|
||||
|
||||
Скопировать `site/app.py`, заменить:
|
||||
- `VERSION`
|
||||
- `MAX_CONTENT_LENGTH` если нужен другой лимит
|
||||
- Импорт своих blueprint'ов
|
||||
|
||||
### Шаг 5: session.py
|
||||
|
||||
Скопировать `site/session.py` как есть. Это in-memory хранилище загруженных файлов и результатов.
|
||||
|
||||
### Шаг 6: Интеграция бизнес-логики
|
||||
|
||||
Твоя функция обработки должна принимать тот же интерфейс что и `obfuscate_files`:
|
||||
|
||||
```python
|
||||
def твоя_функция(files, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
files: list of (filename: str, content: bytes, mimetype: str)
|
||||
Returns:
|
||||
zip_bytes: bytes — ZIP-архив с результатами
|
||||
csv_str: str — CSV с маппингом (или "")
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Чек-лист перед деплоем
|
||||
|
||||
- [ ] `/health` возвращает `{"ok": true}`
|
||||
- [ ] `MAX_CONTENT_LENGTH` достаточен для файлов
|
||||
- [ ] `no_cache` after_request есть
|
||||
- [ ] `X-Accel-Buffering: no` на SSE-эндпоинте
|
||||
- [ ] `GeneratorExit` в каждом `yield` SSE-генератора
|
||||
- [ ] `stream_with_context` оборачивает генератор
|
||||
- [ ] `fetch('/health')` при загрузке страницы
|
||||
- [ ] `beforeunload` → `resetAll()`
|
||||
- [ ] `activeXHR` и `activeES` очищаются перед новым запуском
|
||||
- [ ] `fetch` вместо XHR (XHR на первом запросе даёт RST)
|
||||
- [ ] Выбор файлов — добавляет, не стирает (с дедупом)
|
||||
- [ ] `Connection: close` — **НЕ ставить** (Waitress/PEP 3333 запрещает hop-by-hop)
|
||||
|
||||
---
|
||||
|
||||
## Ссылки на файлы-образцы
|
||||
|
||||
| Что | Где в drhider |
|
||||
|-----|---------------|
|
||||
| HTML/JS/CSS фронтенд | `site/templates/index.html` |
|
||||
| API (upload + SSE + download) | `site/routes/api_bp.py` |
|
||||
| Health probe | `site/routes/health_bp.py` |
|
||||
| Точка входа Flask | `site/app.py` |
|
||||
| In-memory сессии | `site/session.py` |
|
||||
| Полная архитектура | `docs/ARCHITECTURE.md` |
|
||||
| Решённые проблемы кластера | `PROBLEM-AND-SOLUTION.md` |
|
||||
@@ -0,0 +1,90 @@
|
||||
# Корневое решение: MSS clamping через Cilium
|
||||
|
||||
**Дата:** 2026-07-13 (обновлено после валидации Opus)
|
||||
**Статус:** План для девопса
|
||||
**Валидация:** [Opus response](../History/2026-07-13-opus-response-mss-clamping.md)
|
||||
|
||||
---
|
||||
|
||||
## Текущий костыль
|
||||
|
||||
`externalTrafficPolicy: Local` + `kubectl scale replicas=4` (сбрасывается Штурвалом каждые ~час).
|
||||
|
||||
## Корень проблемы
|
||||
|
||||
Geneve-туннель добавляет ~50 байт → превышение underlay MTU → ICMP Frag Needed блокируется → TCP stall 51с.
|
||||
|
||||
## Решение
|
||||
|
||||
Выставить корректный MTU в Cilium, чтобы пакеты с Geneve-заголовком всегда влазили.
|
||||
|
||||
**Важно:** `mtu` в cilium-config — это MTU **underlay-сети** (физической/VM-сети между нодами). Cilium сам вычитает 50 байт на Geneve:
|
||||
```
|
||||
mtu: X → туннель = X − 50 → поды = X − 50
|
||||
```
|
||||
|
||||
## Что сделать девопсу
|
||||
|
||||
## Замер (выполнен 2026-07-13)
|
||||
|
||||
```bash
|
||||
kubectl run mtu-test --rm -it --restart=Never --image=nicolaka/netshoot -- ping -c 2 -M do -s 1472 172.16.0.9
|
||||
# → Message too large (1500 не проходит)
|
||||
|
||||
kubectl run mtu-test --rm -it --restart=Never --image=nicolaka/netshoot -- ping -c 2 -M do -s 1422 172.16.0.9
|
||||
# → 1430 bytes from 172.16.0.9 (1450 проходит)
|
||||
|
||||
**Результат:** underlay MTU = **1450**, туннель = **1400**.
|
||||
|
||||
### Шаг 1: Выставить MTU в Cilium
|
||||
|
||||
```bash
|
||||
kubectl edit configmap -n kube-system cilium-config
|
||||
# mtu: <измеренное значение>
|
||||
```
|
||||
|
||||
### Шаг 2: Применить изменения
|
||||
|
||||
```bash
|
||||
# Перезапустить агенты Cilium (ConfigMap сам не применяется)
|
||||
kubectl rollout restart ds/cilium -n kube-system
|
||||
|
||||
# Пересоздать поды приложения (ingress, drhider — чтобы veth получили новый MTU)
|
||||
kubectl rollout restart deploy -n ingress shturval-ingress-controller-controller
|
||||
```
|
||||
|
||||
### Шаг 3: Вернуть Cluster policy
|
||||
|
||||
```bash
|
||||
kubectl patch svc -n ingress shturval-ingress-controller-controller \
|
||||
-p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
|
||||
```
|
||||
|
||||
### Шаг 4: Верификация
|
||||
|
||||
```bash
|
||||
# Проверить что Cilium применил MTU
|
||||
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
|
||||
ip link show | grep -E 'cilium|geneve'
|
||||
|
||||
# Проверить MSS в SYN-пакетах
|
||||
tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0' -vv | grep mss
|
||||
```
|
||||
|
||||
Функционально:
|
||||
- Загрузить файл > 5 MB через drhider — без паузы 51с
|
||||
- F5 несколько раз — без ERR_TIMED_OUT
|
||||
- Проверить Keycloak и другие сервисы
|
||||
|
||||
## Почему это грамотно
|
||||
|
||||
- Решает корневую причину (PMTU blackhole), не симптомы
|
||||
- Не трогает логику Штурвала (ingress остаётся на 2 репликах)
|
||||
- Работает для всего кластера
|
||||
- Стандартная практика для туннельного CNI
|
||||
|
||||
## Примечания
|
||||
|
||||
- **iptables как fallback ненадёжен:** Cilium с eBPF может обходить netfilter FORWARD. Если нужно — правило на **всех 4 нодах**, но лучше через `cilium-config`.
|
||||
- **Понижение MTU безопасно** (меньше = консервативнее). Максимум — микро-потеря throughput.
|
||||
- **Не смешивать с проблемой реплик.** При `Cluster` 2 реплик достаточно — ручной scale не нужен.
|
||||
@@ -0,0 +1,5 @@
|
||||
НЕ СПЕШИ ! не ошибайся. НЕ ПРЕДПОЛАГАЙ !
|
||||
не надо ДОГАДОК !
|
||||
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
|
||||
есть сомнения - лучше остановись и спроси
|
||||
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
|
||||
@@ -0,0 +1,75 @@
|
||||
# Почему Штурвал не рассчитан на underlay 1450
|
||||
|
||||
Ответы Соннета, 2026-07-14.
|
||||
|
||||
---
|
||||
|
||||
## Почему дефолтный MTU 1500, а не 1400
|
||||
|
||||
Штурвал — managed-платформа. Разработчики не знают MTU конкретной площадки.
|
||||
|
||||
- **Стандартный дата-центр:** underlay 1500 или Jumbo Frames 9000. Pod MTU 1500 + Geneve 50 = 1550 — легко влезает.
|
||||
- **Nubes — исключение:** underlay 1450 (физика 1500 + свой VXLAN 50 байт для SDN). Штурвал не тестировался конкретно на этой площадке.
|
||||
- **Снижать MTU глобально «на всякий случай» — плохо:** MTU 1400 снижает пропускную способность на ~7%. Для большинства клиентов это ненужная жертва.
|
||||
|
||||
Проблема флапает (при удачном размещении подов работает), поэтому жалобы могли не дойти до разработчиков.
|
||||
|
||||
**Вывод:** классическое «работает на наших серверах». Не баг в коде, а пробел в документации/онбординге.
|
||||
|
||||
---
|
||||
|
||||
## Почему underlay именно 1450
|
||||
|
||||
Это признак VXLAN:
|
||||
|
||||
```
|
||||
Outer Ethernet: 14 б
|
||||
Outer IP: 20 б
|
||||
UDP: 8 б
|
||||
VXLAN header: 8 б
|
||||
─────────────────────
|
||||
Итого: 50 б
|
||||
```
|
||||
|
||||
Физика у Nubes — стандартные 1500. Поверх — VXLAN для SDN (мультитенантность, изоляция). Арендаторам остаётся 1500 − 50 = 1450.
|
||||
|
||||
**Почему не Jumbo Frames 9000:** Jumbo Frames требуют чтобы все коммутаторы, сетевые карты и топология поддерживали их. Настраивается при строительстве ЦОД. Скорее всего Nubes — арендованные мощности или исторически не включили. 9000 — привилегия крупных облаков (AWS, GCP), владеющих своим железом от и до.
|
||||
|
||||
**Как проверить на ноде:**
|
||||
|
||||
```bash
|
||||
# Пройдёт если MTU ≥1500 (1472 + 28 заголовков = 1500)
|
||||
ping -M do -s 1472 <ip_шлюза>
|
||||
|
||||
# Пройдёт при 1450 (1422 + 28 = 1450), упадёт на 1423
|
||||
ping -M do -s 1422 <ip_шлюза>
|
||||
ping -M do -s 1423 <ip_шлюза> # должен дать "Frag needed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Почему 1400 а не ниже
|
||||
|
||||
Geneve-туннели **независимы**. Каждый переход — отдельная инкапсуляция:
|
||||
|
||||
```
|
||||
Pod отправляет 1400 байт
|
||||
→ Нода A → Нода B: 1400 + 50 (Geneve) = 1450 ✅
|
||||
→ Нода B → Нода C: снова 1400 + 50 = 1450 ✅
|
||||
```
|
||||
|
||||
Второй туннель берёт **расшифрованный** пакет (снова 1400) и заново оборачивает. Не накапливается. Если бы накапливалось — нужно было бы 1350 (1350+50+50=1450). Но нет — 1400 достаточно для любого количества переходов.
|
||||
|
||||
---
|
||||
|
||||
## Что сказать девопсам
|
||||
|
||||
> Привет! Столкнулся с проблемой при работе managed-сервисов на Штурвале.
|
||||
>
|
||||
> У Nubes underlay MTU = 1450 (стандарт 1500 − 50 байт своего VXLAN). При дефолтном Cilium MTU 1500 любой cross-node трафик через Geneve даёт пакет 1550 — не влазит, HTTP POST зависает на ~51с.
|
||||
>
|
||||
> Решение: `mtu: 1400` в Cilium при создании кластера. 1400 + 50 (Geneve) = 1450 — ровно в канал. Даже два Geneve-перехода — достаточно (туннели независимы).
|
||||
>
|
||||
> Трогать существующие кластеры не предлагаю — там риск. Но можно добавить в шаблон/чеклист для новых кластеров на Nubes.
|
||||
|
||||
Тон — коллегиальный. Не «вы облажались», а «нашёл нюанс, вот решение, давайте зафиксируем».
|
||||
-557
@@ -1,557 +0,0 @@
|
||||
"""
|
||||
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
"""
|
||||
DRHIDER_VERSION = "1.3"
|
||||
import io
|
||||
import csv
|
||||
import re
|
||||
import random
|
||||
import string
|
||||
import zipfile
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Regex-паттерны для обнаружения сущностей
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_PATTERNS: Dict[str, str] = {
|
||||
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
|
||||
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
|
||||
# 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
"inn_ul": r'ИНН\s*\d{10}',
|
||||
"ogrn": r'ОГРН\s*\d{13}',
|
||||
"kpp": r'КПП\s*\d{9}',
|
||||
"bik": r'БИК\s*\d{9}',
|
||||
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
|
||||
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
|
||||
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
|
||||
}
|
||||
|
||||
# Фирмы — обнаружение по шаблону
|
||||
COMPANY_PATTERN = re.compile(
|
||||
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# ФИО — Фамилия + инициалы или полное имя (только кириллица)
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+\s+[А-Я][а-я]+\s+[А-Я][а-я]+'
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Генераторы фиктивных значений
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
# Словари русских имён
|
||||
RU_SURNAMES = ["Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
|
||||
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков", "Алексеев",
|
||||
"Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев"]
|
||||
RU_NAMES = ["Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
|
||||
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел", "Виктор", "Олег"]
|
||||
RU_PATRONYMICS = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
|
||||
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
|
||||
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович"]
|
||||
|
||||
RU_CITIES = ["Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону",
|
||||
"Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград"]
|
||||
RU_STREETS = ["Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная"]
|
||||
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
|
||||
def _checksum_inn10(inn: str) -> str:
|
||||
"""Контрольная сумма для 10-значного ИНН."""
|
||||
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-значного ИНН (первые 10 цифр)."""
|
||||
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:
|
||||
"""Контрольная сумма для ОГРН (12 цифр → остаток от деления на 11)."""
|
||||
s = int(ogrn) % 11
|
||||
return str(s % 10)
|
||||
|
||||
|
||||
def _random_digits(n: int) -> str:
|
||||
return ''.join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
|
||||
def _random_letters(n: int) -> str:
|
||||
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
|
||||
|
||||
|
||||
def generate_phone(_: str) -> str:
|
||||
code = random.choice(["495", "499", "812", "383", "343"])
|
||||
return f"+7 ({code}) {_random_digits(3)}-{_random_digits(2)}-{_random_digits(2)}"
|
||||
|
||||
|
||||
def generate_email(original: str) -> str:
|
||||
"""Генерирует email с тем же форматом."""
|
||||
domain = random.choice(FAKE_DOMAINS)
|
||||
local = _random_letters(random.randint(5, 10))
|
||||
return f"{local}@{domain}"
|
||||
|
||||
|
||||
def generate_inn10(original: str) -> str:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
def generate_ogrn(original: str) -> str:
|
||||
prefix = re.match(r'ОГРН\s*', original, re.IGNORECASE).group(0)
|
||||
base = "1" + _random_digits(11)
|
||||
return prefix + base + _checksum_ogrn(base)
|
||||
|
||||
|
||||
def generate_kpp(original: str) -> str:
|
||||
prefix = re.match(r'КПП\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
|
||||
|
||||
|
||||
def generate_bik(original: str) -> str:
|
||||
prefix = re.match(r'БИК\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "04" + _random_digits(7)
|
||||
|
||||
|
||||
def generate_rs(original: str) -> str:
|
||||
prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "40702" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_ks(original: str) -> str:
|
||||
prefix = re.match(r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "30101" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_passport(original: str) -> str:
|
||||
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)}"
|
||||
|
||||
|
||||
def generate_company(_: str) -> str:
|
||||
forms = ["ООО", "ЗАО", "АО"]
|
||||
nouns = ["Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат"]
|
||||
return f'{random.choice(forms)} «{random.choice(nouns)}»'
|
||||
|
||||
|
||||
def generate_person(_: str) -> str:
|
||||
s = random.choice(RU_SURNAMES)
|
||||
n = random.choice(RU_NAMES)
|
||||
p = random.choice(RU_PATRONYMICS)
|
||||
return f"{s} {n[0]}.{p[0]}."
|
||||
|
||||
|
||||
def generate_address(_: str) -> str:
|
||||
city = random.choice(RU_CITIES)
|
||||
street = random.choice(RU_STREETS)
|
||||
house = random.randint(1, 200)
|
||||
return f"{city}, ул. {street}, д. {house}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Основной класс
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TwoPassObfuscator:
|
||||
"""Двухпроходный обфускатор: сбор сущностей → замена."""
|
||||
|
||||
def __init__(self, llm_client=None):
|
||||
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
|
||||
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
|
||||
self._sorted_keys: List[str] = [] # ключи mapping, отсортированные по длине (убывание)
|
||||
self._llm_client = llm_client
|
||||
|
||||
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Главная точка входа.
|
||||
|
||||
Args:
|
||||
files: [(original_filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
(zip_bytes, mapping_csv_string)
|
||||
"""
|
||||
# --- Распаковать ZIP-файлы ---
|
||||
files = self._expand_zips(files)
|
||||
# --- Конвертировать PDF → DOCX ---
|
||||
files = self._convert_pdfs_to_docx(files)
|
||||
|
||||
try:
|
||||
# --- Проход 1: сбор сущностей ---
|
||||
all_texts: Dict[str, str] = {}
|
||||
all_docx: Dict[str, object] = {}
|
||||
|
||||
for fname, content, ctype in files:
|
||||
text, doc = self._extract_text(fname, content, ctype)
|
||||
all_texts[fname] = text
|
||||
if doc is not None:
|
||||
all_docx[fname] = doc
|
||||
if text and text != "[DOC binary — not parsed]":
|
||||
self._scan_regex(text)
|
||||
|
||||
if self._llm_client:
|
||||
self._scan_llm_ner(all_texts)
|
||||
|
||||
# Предсортировать ключи один раз для _apply_replacements
|
||||
self._sorted_keys = sorted(self._mapping.keys(), key=len, reverse=True)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
results = []
|
||||
for fname, content, ctype in files:
|
||||
obf_content = content
|
||||
if fname.endswith('.doc'):
|
||||
# .doc — бинарный, оставляем как есть
|
||||
pass
|
||||
elif fname in all_docx:
|
||||
obf_content = self._replace_in_docx(all_docx[fname])
|
||||
else:
|
||||
txt = all_texts.get(fname, '')
|
||||
obf_content = self._replace_in_text(txt, fname)
|
||||
results.append((fname, obf_content))
|
||||
|
||||
csv_str = self._build_mapping_csv()
|
||||
return self._build_zip(results, csv_str), csv_str
|
||||
|
||||
finally:
|
||||
self._mapping.clear()
|
||||
self._regex_replacements.clear()
|
||||
self._sorted_keys.clear()
|
||||
|
||||
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
|
||||
import pdfplumber
|
||||
from docx import Document as DocxDocument
|
||||
result = []
|
||||
existing_names = {f[0] for f in files}
|
||||
for fname, content, ctype in files:
|
||||
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 table:
|
||||
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)
|
||||
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)
|
||||
result.append((fname, content, ctype))
|
||||
return result
|
||||
|
||||
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
|
||||
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
|
||||
result = []
|
||||
for fname, content, ctype in files:
|
||||
if fname.lower().endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
if len(zf.infolist()) > 500:
|
||||
log.warning("ZIP too many files, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
total_uncompressed = 0
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
# ZIP bomb: ratio check
|
||||
if info.compress_size > 0:
|
||||
ratio = info.file_size / info.compress_size
|
||||
if ratio > 100:
|
||||
log.warning("ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname)
|
||||
result.append((fname, content, ctype))
|
||||
break
|
||||
# cp437 → utf8 (как в compare/unzip.py)
|
||||
name = info.filename
|
||||
try:
|
||||
name = name.encode("cp437").decode("utf-8", errors="replace")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
# Убрать path traversal
|
||||
name = os.path.basename(name)
|
||||
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 > 500 * 1024 * 1024: # 500 MB
|
||||
log.warning("ZIP uncompressed limit exceeded, stopping: %s", fname)
|
||||
break
|
||||
result.append((name, inner_data, ""))
|
||||
except Exception as e:
|
||||
log.warning("Failed to expand ZIP %s: %s", fname, e)
|
||||
result.append((fname, content, ctype))
|
||||
else:
|
||||
result.append((fname, content, ctype))
|
||||
return result
|
||||
|
||||
# --- Проход 1: обнаружение ---
|
||||
|
||||
def _extract_text(self, fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
|
||||
"""Извлечь текст из файла. Возвращает (text, docx_document_or_None)."""
|
||||
doc = None
|
||||
text = ""
|
||||
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
if ext == '.docx':
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
doc = Document(io.BytesIO(content))
|
||||
text = "\n".join(p.text for p in doc.paragraphs)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
text += "\n" + " | ".join(cell.text for cell in row.cells)
|
||||
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
t = page.extract_text()
|
||||
if t:
|
||||
text += t + "\n"
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
text += "\n" + " | ".join(str(c) if c else "" for c in row)
|
||||
|
||||
elif ext == '.doc':
|
||||
# .doc — бинарный формат, без libreoffice не парсим
|
||||
# Пропускаем без изменений (не обфусцируем)
|
||||
text = "[DOC binary — not parsed]"
|
||||
return text, None
|
||||
|
||||
else:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
|
||||
return text, doc
|
||||
|
||||
def _scan_regex(self, text: str):
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен."""
|
||||
for entity_type, pattern in ENTITY_PATTERNS.items():
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
|
||||
self._mapping[original] = generator(original)
|
||||
|
||||
# Компании (кроме НУБЕС — это Исполнитель)
|
||||
for match in COMPANY_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
|
||||
continue # Исполнитель — не заменяем
|
||||
self._mapping[original] = generate_company(original)
|
||||
|
||||
# ФИО (Фамилия И.О.)
|
||||
for match in PERSON_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
self._mapping[original] = generate_person(original)
|
||||
|
||||
def _scan_llm_ner(self, all_texts: Dict[str, str]):
|
||||
"""LLM NER для обнаружения имён и адресов во всех файлах."""
|
||||
combined = "\n\n---FILE---\n\n".join(
|
||||
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
|
||||
)
|
||||
prompt = (
|
||||
"Ты — система обнаружения персональных данных в документах. "
|
||||
"Найди ВСЕ следующие сущности в тексте ниже:\n\n"
|
||||
"1. ФИО (полные и сокращённые — 'Иванов И.И.', 'Петров А.С.')\n"
|
||||
"2. Названия компаний-контрагентов (не 'НУБЕС')\n"
|
||||
"3. Почтовые адреса\n"
|
||||
"4. Паспортные данные\n\n"
|
||||
"Формат ответа — JSON-массив:\n"
|
||||
'[{"type": "person"|"company"|"address"|"passport", "value": "найденный текст"}]\n\n'
|
||||
f"Текст:\n{combined[:8000]}"
|
||||
)
|
||||
try:
|
||||
raw = self._llm_client.complete(prompt)
|
||||
import json
|
||||
# Игнорируем markdown-обёртку
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
entities = json.loads(raw)
|
||||
for ent in entities:
|
||||
val = ent.get("value", "").strip()
|
||||
if val and val not in self._mapping:
|
||||
if ent.get("type") == "person":
|
||||
self._mapping[val] = generate_person(val)
|
||||
elif ent.get("type") == "company":
|
||||
self._mapping[val] = generate_company(val)
|
||||
elif ent.get("type") == "address":
|
||||
self._mapping[val] = generate_address(val)
|
||||
elif ent.get("type") == "passport":
|
||||
self._mapping[val] = generate_passport(val)
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
|
||||
def _replace_in_docx(self, doc) -> bytes:
|
||||
"""Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные."""
|
||||
for para in doc.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = self._apply_replacements(full_text)
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = self._apply_replacements(full_text)
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _replace_in_text(self, text: str, fname: str) -> bytes:
|
||||
"""Заменить сущности в plain text (для PDF и прочих)."""
|
||||
replaced = self._apply_replacements(text)
|
||||
# Для PDF пока отдаём текст (MVP — без сохранения форматирования PDF)
|
||||
return replaced.encode('utf-8')
|
||||
|
||||
def _apply_replacements(self, text: str) -> str:
|
||||
"""Применить все замены из словаря mapping к строке. Сначала длинные, потом короткие."""
|
||||
result = text
|
||||
for original in self._sorted_keys:
|
||||
replacement = self._mapping[original]
|
||||
# Границы слова — если сущность начинается и заканчивается на \w
|
||||
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
|
||||
|
||||
# --- Сборка выдачи ---
|
||||
|
||||
def _build_zip(self, files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
"""Собрать ZIP с обфусцированными файлами + mapping.csv."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for fname, content in files:
|
||||
info = zipfile.ZipInfo(fname)
|
||||
info.flag_bits |= 0x800 # UTF-8 filename
|
||||
zf.writestr(info, content)
|
||||
if mapping_csv:
|
||||
zf.writestr("mapping.csv", '\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'))
|
||||
return buf.getvalue()
|
||||
|
||||
def _build_mapping_csv(self) -> str:
|
||||
"""Собрать mapping.csv."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["тип_данных", "оригинал", "замена"])
|
||||
for original, replacement in sorted(self._mapping.items()):
|
||||
etype = "text"
|
||||
# inn_fl ДО inn_ul (12 цифр vs 10)
|
||||
for t in ["phone", "email", "inn_fl", "inn_ul", "ogrn", "kpp", "bik", "rs", "ks", "passport"]:
|
||||
pat = ENTITY_PATTERNS.get(t, "")
|
||||
if pat and re.match(pat, original, re.IGNORECASE):
|
||||
etype = t
|
||||
break
|
||||
if COMPANY_PATTERN.match(original):
|
||||
etype = "company"
|
||||
writer.writerow([etype, original, replacement])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Маппинг entity_type → генератор
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_GENERATORS: Dict[str, Callable] = {
|
||||
"phone": generate_phone,
|
||||
"email": generate_email,
|
||||
"inn_ul": generate_inn10,
|
||||
"inn_fl": generate_inn12,
|
||||
"ogrn": generate_ogrn,
|
||||
"kpp": generate_kpp,
|
||||
"bik": generate_bik,
|
||||
"rs": generate_rs,
|
||||
"ks": generate_ks,
|
||||
"passport": generate_passport,
|
||||
}
|
||||
|
||||
|
||||
def obfuscate_files(files: List[Tuple[str, bytes, str]], llm_client=None) -> Tuple[bytes, str]:
|
||||
"""Удобная функция: обфусцировать список файлов → (zip_bytes, csv_string)."""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
"""
|
||||
|
||||
from .obfuscator import TwoPassObfuscator, obfuscate_files
|
||||
from .llm_client import LLMClient
|
||||
|
||||
__all__ = ["TwoPassObfuscator", "obfuscate_files", "LLMClient"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Сборка результата обфускации.
|
||||
|
||||
Два метода:
|
||||
1. _build_zip — упаковка обфусцированных файлов в ZIP
|
||||
2. _build_mapping_csv — генерация mapping.csv с таблицей замен
|
||||
"""
|
||||
|
||||
import io
|
||||
import csv
|
||||
import zipfile
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
|
||||
|
||||
|
||||
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
"""Собрать ZIP-архив с обфусцированными файлами.
|
||||
|
||||
В архив добавляются:
|
||||
- Все обфусцированные файлы (с оригинальными именами)
|
||||
- mapping.csv — таблица соответствия оригинал→замена (если не пустая)
|
||||
|
||||
Имена файлов в архиве — UTF-8 (бит 11 в flag_bits).
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes), ...]
|
||||
mapping_csv: Строка CSV с таблицей замен (опционально)
|
||||
|
||||
Returns:
|
||||
Бинарное содержимое ZIP-архива
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Добавляем обфусцированные файлы
|
||||
for fname, content in files:
|
||||
info = zipfile.ZipInfo(fname)
|
||||
info.date_time = time.localtime(time.time() + 3 * 3600)[:6] # MSK (UTC+3)
|
||||
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()
|
||||
|
||||
|
||||
def build_mapping_csv(mapping: Dict[str, str]) -> str:
|
||||
"""Собрать mapping.csv — таблицу соответствия оригинал → замена.
|
||||
|
||||
Колонки:
|
||||
- тип_данных: тип сущности (phone, email, inn_ul, company, ...)
|
||||
- оригинал: исходное значение из документа
|
||||
- замена: фиктивное значение
|
||||
|
||||
Тип определяется проверкой каждого значения через ENTITY_PATTERNS
|
||||
и COMPANY_PATTERN. Если ни один паттерн не подошёл — тип "text".
|
||||
|
||||
Args:
|
||||
mapping: Словарь {оригинал: замена}
|
||||
|
||||
Returns:
|
||||
Строка в формате CSV
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["тип_данных", "оригинал", "замена"])
|
||||
|
||||
# Порядок проверки типов: inn_fl ДО inn_ul (12 цифр vs 10)
|
||||
type_order = [
|
||||
"phone", "email", "inn_fl", "inn_ul", "ogrn",
|
||||
"kpp", "bik", "rs", "ks", "passport",
|
||||
]
|
||||
|
||||
for original, replacement in sorted(mapping.items()):
|
||||
# Определяем тип сущности
|
||||
etype = "text" # По умолчанию
|
||||
|
||||
for t in type_order:
|
||||
pat = ENTITY_PATTERNS.get(t, "")
|
||||
if pat and re.match(pat, original, re.IGNORECASE):
|
||||
etype = t
|
||||
break
|
||||
|
||||
# Компании проверяем отдельно (COMPANY_PATTERN не в ENTITY_PATTERNS)
|
||||
if COMPANY_PATTERN.match(original):
|
||||
etype = "company"
|
||||
|
||||
writer.writerow([etype, original, replacement])
|
||||
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Константы и словари для DrHider.
|
||||
|
||||
Содержит:
|
||||
- ENTITY_PATTERNS — regex-паттерны для обнаружения сущностей
|
||||
- COMPANY_PATTERN — паттерн для обнаружения названий компаний
|
||||
- PERSON_PATTERN — паттерн для обнаружения ФИО
|
||||
- Словари русских имён, городов, улиц
|
||||
- Список фиктивных доменов для email
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Regex-паттерны для обнаружения сущностей в тексте документов
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ENTITY_PATTERNS = {
|
||||
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
|
||||
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
|
||||
# 12-значный ИНН ДО 10-значного — иначе 12-значный матчится как 10-значный
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
"inn_ul": r'ИНН\s*\d{10}',
|
||||
"ogrn": r'ОГРН\s*\d{13}',
|
||||
"kpp": r'КПП\s*\d{9}',
|
||||
"bik": r'БИК\s*\d{9}',
|
||||
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
|
||||
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
|
||||
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Паттерны для LLM-независимого обнаружения (regex)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Названия компаний: ООО/ЗАО/АО/ПАО/ИП/ТОО + название в кавычках или без
|
||||
COMPANY_PATTERN = re.compile(
|
||||
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ФИО: Фамилия + инициалы (Иванов И.И.) или полное имя (Иванов Иван Иванович)
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Словари для генерации фиктивных значений
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Русские фамилии (20 самых распространённых)
|
||||
RU_SURNAMES = [
|
||||
"Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
|
||||
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков",
|
||||
"Алексеев", "Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов",
|
||||
"Степанов", "Николаев",
|
||||
]
|
||||
|
||||
# Русские мужские имена
|
||||
RU_NAMES = [
|
||||
"Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
|
||||
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел",
|
||||
"Виктор", "Олег",
|
||||
]
|
||||
|
||||
# Русские отчества
|
||||
RU_PATRONYMICS = [
|
||||
"Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
|
||||
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
|
||||
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович",
|
||||
]
|
||||
|
||||
# Города России (15 крупнейших)
|
||||
RU_CITIES = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск",
|
||||
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград",
|
||||
]
|
||||
|
||||
# Названия улиц
|
||||
RU_STREETS = [
|
||||
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
|
||||
]
|
||||
|
||||
# Фиктивные домены для генерации email
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Пулы для гибридных токенов (шаблон + номер)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Существительные для названий компаний (20)
|
||||
COMPANY_NOUNS = [
|
||||
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан",
|
||||
"Спектр", "Формат", "Модуль", "Ресурс", "Платформа",
|
||||
"Система", "Комплекс", "Проект", "Решение", "Сервис",
|
||||
]
|
||||
|
||||
# Города для адресов (20)
|
||||
CITY_POOL = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний_Новгород", "Челябинск", "Самара", "Омск",
|
||||
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь",
|
||||
"Волгоград", "Краснодар", "Саратов", "Тюмень", "Иркутск", "Хабаровск",
|
||||
]
|
||||
|
||||
# Улицы для адресов (20)
|
||||
STREET_POOL = [
|
||||
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
|
||||
"Школьная", "Лесная", "Полевая", "Строителей", "Набережная",
|
||||
"Парковая", "Солнечная", "Зелёная", "Юбилейная", "Весенняя",
|
||||
]
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Извлечение текста из документов — все форматы → Markdown.
|
||||
|
||||
Поддерживает:
|
||||
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
|
||||
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
|
||||
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
|
||||
- .txt и прочие → как есть
|
||||
|
||||
Также содержит:
|
||||
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DOCX → Markdown
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def docx_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать DOCX в Markdown с сохранением базового форматирования.
|
||||
|
||||
Маппинг стилей:
|
||||
- Heading 1/2/3 → # / ## / ###
|
||||
- Bold → **текст**
|
||||
- Italic → *текст*
|
||||
- Таблицы → | col1 | col2 | (с разделителем)
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .docx файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown
|
||||
"""
|
||||
from docx import Document
|
||||
|
||||
doc = Document(io.BytesIO(content))
|
||||
lines = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
if not para.text.strip():
|
||||
lines.append("") # Пустая строка — разделитель абзацев
|
||||
continue
|
||||
|
||||
text = para.text
|
||||
|
||||
# Применяем форматирование из runs (bold, italic)
|
||||
if para.runs:
|
||||
formatted_parts = []
|
||||
for run in para.runs:
|
||||
part = run.text
|
||||
if run.bold:
|
||||
part = f"**{part}**"
|
||||
if run.italic:
|
||||
part = f"*{part}*"
|
||||
formatted_parts.append(part)
|
||||
text = "".join(formatted_parts)
|
||||
|
||||
# Стиль абзаца
|
||||
style_name = para.style.name if para.style else ""
|
||||
|
||||
if style_name.startswith("Heading"):
|
||||
# Heading 1 → #, Heading 2 → ##, ...
|
||||
try:
|
||||
level = int(style_name.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
level = 1
|
||||
prefix = "#" * min(level, 6)
|
||||
lines.append(f"{prefix} {text}")
|
||||
else:
|
||||
lines.append(text)
|
||||
|
||||
# ── Таблицы ──
|
||||
for table in doc.tables:
|
||||
lines.append("") # Отступ перед таблицей
|
||||
for ri, row in enumerate(table.rows):
|
||||
cells = [cell.text.replace("\n", " ").strip() for cell in row.cells]
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
# Разделитель после первой строки (заголовка таблицы)
|
||||
if ri == 0:
|
||||
lines.append("|" + "|".join(["---"] * len(cells)) + "|")
|
||||
lines.append("") # Отступ после таблицы
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PDF → Markdown
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def pdf_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать PDF в Markdown (только текст + таблицы, без форматирования).
|
||||
|
||||
pdfplumber не различает стили (bold/italic/heading), поэтому
|
||||
всё извлекается как plain text.
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .pdf файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown (plain text + таблицы)
|
||||
"""
|
||||
import pdfplumber
|
||||
|
||||
lines = []
|
||||
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
# ── Текст страницы ──
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
lines.append(text)
|
||||
lines.append("")
|
||||
|
||||
# ── Таблицы ──
|
||||
tables = page.extract_tables()
|
||||
for table in tables:
|
||||
if not table:
|
||||
continue
|
||||
|
||||
# Чистим строки таблицы
|
||||
clean_rows = [
|
||||
[str(c or "").strip() for c in (row or [])]
|
||||
for row in table
|
||||
]
|
||||
clean_rows = [r for r in clean_rows if any(r)]
|
||||
|
||||
if not clean_rows:
|
||||
continue
|
||||
|
||||
# Выводим таблицу в MD
|
||||
for ri, row in enumerate(clean_rows):
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
if ri == 0:
|
||||
lines.append("|" + "|".join(["---"] * len(row)) + "|")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DOC → Markdown (через сервис liberta)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def doc_to_markdown(content: bytes) -> str:
|
||||
"""Конвертировать бинарный .doc → .docx (сервис liberta) → Markdown.
|
||||
|
||||
Отправляет .doc на HTTP-сервис конвертации, получает .docx,
|
||||
затем прогоняет через штатный docx_to_markdown().
|
||||
|
||||
Args:
|
||||
content: Бинарное содержимое .doc файла
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown (или сообщение об ошибке)
|
||||
"""
|
||||
import httpx
|
||||
url = os.environ.get(
|
||||
"CONVERT_SERVICE_URL",
|
||||
"https://liberta.containerk8s.dev.nubes.ru/convert"
|
||||
)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.post(
|
||||
url,
|
||||
files={"file": ("input.doc", content, "application/msword")}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return docx_to_markdown(resp.content)
|
||||
err = resp.json().get("error", f"HTTP {resp.status_code}")
|
||||
log.warning("liberta conversion failed: %s", err)
|
||||
return f"[DOC — conversion failed: {err}]"
|
||||
except httpx.TimeoutException:
|
||||
return "[DOC — conversion timed out (>120s)]"
|
||||
except Exception as e:
|
||||
log.warning("liberta error: %s", e)
|
||||
return f"[DOC — conversion error: {e}]"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Единая точка входа
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
|
||||
"""Извлечь текст из одного файла → Markdown.
|
||||
|
||||
Args:
|
||||
fname: Имя файла (с расширением)
|
||||
content: Бинарное содержимое файла
|
||||
ctype: MIME-тип (зарезервировано, не используется)
|
||||
|
||||
Returns:
|
||||
Строка в формате Markdown
|
||||
"""
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
# ── .docx: полный Markdown с форматированием ──
|
||||
if ext == '.docx':
|
||||
try:
|
||||
return docx_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
# ── .pdf: текст + таблицы ──
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
return pdf_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
|
||||
elif ext == '.doc':
|
||||
return doc_to_markdown(content)
|
||||
|
||||
# ── .txt и прочие ──
|
||||
else:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ZIP-распаковка (без изменений)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
|
||||
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
|
||||
|
||||
Не-ZIP файлы проходят без изменений.
|
||||
|
||||
Защита от ZIP-бомб:
|
||||
- Максимум 500 файлов в архиве
|
||||
- Ratio file_size/compress_size не более 100:1
|
||||
- Накопительный размер распакованных данных не более 500 MB
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
Новый список файлов (ZIP раскрыты, остальные как есть)
|
||||
"""
|
||||
result: List[Tuple[str, bytes, str]] = []
|
||||
|
||||
for fname, content, ctype in files:
|
||||
if not fname.lower().endswith('.zip'):
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
if len(zf.infolist()) > 500:
|
||||
log.warning("ZIP too many files, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
|
||||
total_uncompressed = 0
|
||||
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
|
||||
if info.compress_size > 0:
|
||||
ratio = info.file_size / info.compress_size
|
||||
if ratio > 100:
|
||||
log.warning(
|
||||
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
|
||||
)
|
||||
result.append((fname, content, ctype))
|
||||
break
|
||||
|
||||
name = info.filename
|
||||
try:
|
||||
name = name.encode("cp437").decode("utf-8", errors="replace")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
|
||||
name = os.path.basename(name)
|
||||
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 > 500 * 1024 * 1024:
|
||||
log.warning(
|
||||
"ZIP uncompressed limit exceeded, stopping: %s", fname
|
||||
)
|
||||
break
|
||||
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
LLM-клиент для DrHider.
|
||||
|
||||
Используется в TwoPassObfuscator._scan_llm_ner() для обнаружения
|
||||
имён, компаний, адресов и паспортных данных через LLM API.
|
||||
|
||||
Интерфейс:
|
||||
client = LLMClient()
|
||||
result = client.complete(prompt) # str
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Клиент для вызова LLM API (aillm.ru / OpenAI-совместимый).
|
||||
|
||||
Использует переменные окружения:
|
||||
LLM_API_KEY или LLM_KEY — ключ API
|
||||
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
|
||||
LLM_MODEL — модель (по умолчанию gpt-oss-120b)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализировать клиент. httpx импортируется лениво."""
|
||||
self._httpx = httpx
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
"""Отправить промпт в LLM и вернуть текст ответа.
|
||||
|
||||
Args:
|
||||
prompt: Текст промпта (инструкция + данные для анализа)
|
||||
|
||||
Returns:
|
||||
Текстовый ответ модели (обычно JSON-строка)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: при ошибке HTTP
|
||||
KeyError: при неожиданном формате ответа
|
||||
"""
|
||||
# Ключ API: сначала LLM_KEY, затем LLM_API_KEY (для совместимости)
|
||||
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||
|
||||
# URL и модель с значениями по умолчанию
|
||||
url = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||
model = os.environ.get("LLM_MODEL", "gpt-oss-120b")
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Оркестратор двухпроходной обфускации — класс TwoPassObfuscator.
|
||||
|
||||
Процесс:
|
||||
1. Проход 1 (сбор): извлечь текст → regex-сканирование → LLM-сканирование
|
||||
2. Проход 2 (замена): применить mapping ко всем файлам
|
||||
3. Сборка: ZIP + mapping.csv
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
from . import extractor
|
||||
from . import scanner
|
||||
from . import replacer
|
||||
from . import builder
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
class TwoPassObfuscator:
|
||||
"""Двухпроходный обфускатор документов.
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены ко всем файлам → ZIP с результатом.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах получает
|
||||
одно и то же фиктивное значение.
|
||||
|
||||
Attributes:
|
||||
_mapping: {оригинал: замена} — глобальный словарь
|
||||
_sorted_keys: ключи mapping, отсортированные по длине (убывание)
|
||||
_llm_client: опциональный LLM-клиент для NER
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client=None):
|
||||
"""Инициализировать обфускатор.
|
||||
|
||||
Args:
|
||||
llm_client: Объект с методом .complete(prompt) -> str.
|
||||
Если None — LLM-сканирование не выполняется.
|
||||
"""
|
||||
self._mapping: Dict[str, str] = {}
|
||||
self._sorted_keys: List[str] = []
|
||||
self._counters: Dict[str, int] = {} # Глобальные счётчики токенов
|
||||
self._llm_client = llm_client
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Главная точка входа
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def obfuscate(
|
||||
self, files: List[Tuple[str, bytes, str]]
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Обфусцировать список файлов.
|
||||
|
||||
Все форматы → Markdown → замена → результат.
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string):
|
||||
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv
|
||||
csv_string — содержимое mapping.csv как строка
|
||||
"""
|
||||
# ── Предобработка: распаковать ZIP ──
|
||||
files = extractor.expand_zips(files)
|
||||
|
||||
try:
|
||||
# ── Проход 1: сбор сущностей ──
|
||||
# Извлекаем Markdown из каждого файла
|
||||
all_texts: Dict[str, str] = {}
|
||||
|
||||
for fname, content, ctype in files:
|
||||
text = extractor.extract_text(fname, content, ctype)
|
||||
all_texts[fname] = text
|
||||
|
||||
# Regex-сканирование (быстрое, локальное)
|
||||
if text and not text.startswith("[DOC binary"):
|
||||
scanner.scan_regex(text, self._mapping, self._counters)
|
||||
|
||||
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
||||
if self._llm_client:
|
||||
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client, self._counters)
|
||||
|
||||
# Предсортировать ключи один раз (по убыванию длины)
|
||||
self._sorted_keys = sorted(
|
||||
self._mapping.keys(), key=len, reverse=True
|
||||
)
|
||||
|
||||
# ── Проход 2: замена сущностей ──
|
||||
results: List[Tuple[str, bytes]] = []
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
# Имя файла: заменить расширение на .md
|
||||
md_name = os.path.splitext(fname)[0] + '.md'
|
||||
obf_content = replaced.encode('utf-8')
|
||||
fname = md_name
|
||||
|
||||
results.append((fname, obf_content))
|
||||
|
||||
# ── Сборка результата ──
|
||||
csv_str = builder.build_mapping_csv(self._mapping)
|
||||
zip_data = builder.build_zip(results, csv_str)
|
||||
|
||||
return zip_data, csv_str
|
||||
|
||||
finally:
|
||||
# Очистка состояния (обфускатор может использоваться повторно)
|
||||
self._mapping.clear()
|
||||
self._sorted_keys.clear()
|
||||
self._counters.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Удобная функция для быстрого вызова
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def obfuscate_files(
|
||||
files: List[Tuple[str, bytes, str]], llm_client=None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Обфусцировать список файлов — удобная функция.
|
||||
|
||||
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
llm_client: Опциональный LLM-клиент
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string)
|
||||
"""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Проход 2: замена сущностей в документах.
|
||||
|
||||
Два метода:
|
||||
1. apply_replacements — замена в Markdown/plain-тексте (основной)
|
||||
2. replace_in_docx — замена в DOCX с сохранением форматирования
|
||||
(для опционального DOCX-выхода, будет добавлен позже)
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def _md_tolerant_pattern(original: str) -> str:
|
||||
r"""Паттерн, допускающий ** между частями строки (Markdown bold).
|
||||
|
||||
DOCX → Markdown: жирность добавляет ** вокруг слов.
|
||||
Если ** попадает внутрь сущности (например, **ООО **"**НУБЕС**"),
|
||||
точное совпадение не срабатывает.
|
||||
|
||||
Этот паттерн вставляет (?:\*{1,2})? между каждым словом/символом,
|
||||
позволяя найти сущность даже с ** внутри.
|
||||
|
||||
Args:
|
||||
original: Исходная строка сущности (например, 'ООО "НУБЕС"')
|
||||
|
||||
Returns:
|
||||
Regex-паттерн, находящий ту же строку с ** между частями
|
||||
"""
|
||||
MD = r'(?:\*{1,2})?'
|
||||
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
|
||||
if len(parts) <= 1:
|
||||
return None # Одно слово — ** не может разорвать изнутри
|
||||
return MD + MD.join(re.escape(p) for p in parts) + MD
|
||||
|
||||
|
||||
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str:
|
||||
"""Применить все замены из словаря mapping к строке.
|
||||
|
||||
Ключи применяются в порядке убывания длины (sorted_keys).
|
||||
Это гарантирует, что более длинные совпадения заменяются раньше
|
||||
коротких (например, «ИНН 123456789012» до «ИНН 1234567890»).
|
||||
|
||||
Для сущностей, начинающихся и заканчивающихся на букву/цифру,
|
||||
используются границы слова (\\b), чтобы избежать частичных замен.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
mapping: Словарь {оригинал: замена}
|
||||
sorted_keys: Ключи mapping, отсортированные по длине (убывание)
|
||||
|
||||
Returns:
|
||||
Текст с заменами
|
||||
"""
|
||||
result = text
|
||||
|
||||
for original in sorted_keys:
|
||||
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)
|
||||
|
||||
new_result = re.sub(pattern, replacement, result)
|
||||
|
||||
# Фоллбэк: Markdown-жирность могла разорвать строку
|
||||
md_pattern = _md_tolerant_pattern(original)
|
||||
if md_pattern is not None:
|
||||
new_result = re.sub(md_pattern, replacement, new_result)
|
||||
|
||||
result = new_result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
|
||||
"""Заменить сущности в DOCX-документе с сохранением форматирования.
|
||||
|
||||
Алгоритм для каждого параграфа:
|
||||
1. Склеиваем текст всех runs в одну строку
|
||||
2. Применяем замены
|
||||
3. Пишем результат в первый run, очищаем остальные
|
||||
|
||||
Это нужно потому что python-docx разбивает текст на runs
|
||||
(например, mid-docx форматирование), и сущность может быть
|
||||
разорвана между несколькими runs.
|
||||
|
||||
Args:
|
||||
doc: Объект python-docx Document
|
||||
mapping: Словарь замен
|
||||
sorted_keys: Ключи mapping по убыванию длины
|
||||
|
||||
Returns:
|
||||
Бинарное содержимое изменённого DOCX-файла
|
||||
"""
|
||||
# ── Обработка параграфов ──
|
||||
for para in doc.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
# Склеиваем все runs в одну строку
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = apply_replacements(full_text, mapping, sorted_keys)
|
||||
|
||||
# Если были замены — пишем в первый run, очищаем остальные
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
# ── Обработка таблиц ──
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = apply_replacements(full_text, mapping, sorted_keys)
|
||||
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
# Сохраняем в буфер
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Проход 1: обнаружение сущностей в тексте документов.
|
||||
|
||||
Два метода сканирования:
|
||||
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
|
||||
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
|
||||
|
||||
Замена: гибридные токены — читаемый шаблон + уникальный номер.
|
||||
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from .config import (
|
||||
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
|
||||
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TYPE_POOLS = {
|
||||
"person_name": (RU_SURNAMES, "person"),
|
||||
"person": (RU_SURNAMES, "person"),
|
||||
"company": (COMPANY_NOUNS, "company"),
|
||||
"phone": (["+7_000_000"], "phone"),
|
||||
"email": (["email"], "email"),
|
||||
"address": (CITY_POOL, "address"),
|
||||
"inn": (["ИНН"], "inn"),
|
||||
"inn_ul": (["ИНН"], "inn"),
|
||||
"inn_fl": (["ИНН"], "inn"),
|
||||
"kpp": (["КПП"], "kpp"),
|
||||
"ogrn": (["ОГРН"], "ogrn"),
|
||||
"bik": (["БИК"], "bik"),
|
||||
"passport": (["Паспорт"], "passport"),
|
||||
"bank_account": (["Счёт"], "bank"),
|
||||
"rs": (["РС"], "bank"),
|
||||
"ks": (["КС"], "bank"),
|
||||
"contract_number": (["Договор"], "contract"),
|
||||
"other_id": (["ID"], "other"),
|
||||
}
|
||||
_FALLBACK_POOL = (["Данные"], "data")
|
||||
|
||||
|
||||
def _next_token(entity_type: str, counters: Dict[str, int]) -> str:
|
||||
"""Сгенерировать токен замены: случайный шаблон из пула + номер.
|
||||
|
||||
Одна и та же сущность в mapping получает один и тот же токен
|
||||
(mapping dict гарантирует согласованность).
|
||||
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
|
||||
|
||||
Args:
|
||||
entity_type: Тип сущности (person_name, company, phone...)
|
||||
counters: Глобальные счётчики {prefix_key: count}
|
||||
|
||||
Returns:
|
||||
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
|
||||
"""
|
||||
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
|
||||
template = random.choice(pool)
|
||||
key = f"{prefix_key}_{template}"
|
||||
counters[key] = counters.get(key, 0) + 1
|
||||
return f"{template}_{counters[key]:04d}"
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен.
|
||||
|
||||
Замена: гибридные токены (шаблон + номер).
|
||||
|
||||
Args:
|
||||
text: Текст документа для сканирования
|
||||
mapping: Словарь замен (мутабельный, пополняется)
|
||||
counters: Глобальные счётчики токенов (мутабельный)
|
||||
"""
|
||||
# ── Сущности по regex-паттернам ──
|
||||
for entity_type, pattern in ENTITY_PATTERNS.items():
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token(entity_type, counters)
|
||||
|
||||
# ── Названия компаний ──
|
||||
for match in COMPANY_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token("company", counters)
|
||||
|
||||
# ── ФИО ──
|
||||
for match in PERSON_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if not original or original in mapping:
|
||||
continue
|
||||
mapping[original] = _next_token("person_name", counters)
|
||||
|
||||
|
||||
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
llm_client, counters: Dict[str, int]) -> None:
|
||||
"""Универсальное LLM-обнаружение приватных данных.
|
||||
|
||||
LLM САМА определяет типы. Замена — гибридные токены.
|
||||
|
||||
Args:
|
||||
all_texts: {filename: text_content} — тексты всех файлов
|
||||
mapping: Словарь замен (мутабельный, пополняется)
|
||||
llm_client: Объект с методом .complete(prompt) -> str
|
||||
counters: Глобальные счётчики токенов (мутабельный)
|
||||
"""
|
||||
# Формируем комбинированный текст: имя файла + первые 3000 символов
|
||||
combined = "\n\n---FILE---\n\n".join(
|
||||
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
|
||||
)
|
||||
|
||||
# ── Универсальный промпт: STEP 1 (Исполнитель) + STEP 2 (ПДн) ──
|
||||
already_found = "\n".join(f"- {k}" for k in mapping.keys()) if mapping else "(none)"
|
||||
|
||||
prompt = (
|
||||
"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"{already_found}\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{combined[:8000]}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Отправляем запрос в LLM
|
||||
raw = llm_client.complete(prompt)
|
||||
|
||||
# Чистим markdown-обёртку (если LLM вернула ```json ... ```)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
|
||||
entities = json.loads(raw)
|
||||
|
||||
# Добавляем найденные сущности в mapping
|
||||
for ent in entities:
|
||||
val = ent.get("value", "").strip()
|
||||
if not val or val in mapping:
|
||||
continue
|
||||
|
||||
# Тип — любой (LLM сама придумала)
|
||||
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
|
||||
mapping[val] = _next_token(ent_type, counters)
|
||||
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DrHider standalone server — NO DB dependency. Port 8767."""
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from socketserver import ThreadingMixIn, TCPServer
|
||||
from urllib.parse import urlparse
|
||||
import json, re, os, sys, cgi
|
||||
|
||||
DRHIDER_SERVER_VERSION = "1.3"
|
||||
|
||||
MAX_BODY = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
# ── Загрузка .env ────────────────────────────────────────────────────────
|
||||
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
|
||||
if os.path.exists(_env_path):
|
||||
with open(_env_path) as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if _line and not _line.startswith("#") and "=" in _line:
|
||||
_k, _v = _line.split("=", 1)
|
||||
if _k not in os.environ:
|
||||
os.environ[_k] = _v
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self._send_cors()
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
self._json({"ok": True, "server": "drhider", "version": DRHIDER_SERVER_VERSION})
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/drhider":
|
||||
self._handle_drhider()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
# ── DrHider ──────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_drhider(self):
|
||||
"""Обфускация: multipart files → ZIP."""
|
||||
from drhider import obfuscate_files
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length <= 0 or length > MAX_BODY:
|
||||
self._json({"ok": False, "error": "Файл слишком большой"}, 413)
|
||||
return
|
||||
raw = self.rfile.read(length)
|
||||
ctype = self.headers.get("Content-Type", "")
|
||||
|
||||
_, params = cgi.parse_header(ctype)
|
||||
boundary = params.get("boundary", "")
|
||||
if not boundary:
|
||||
self._json({"ok": False, "error": "No boundary"}, 400)
|
||||
return
|
||||
|
||||
parts = raw.split(b"--" + boundary.encode())
|
||||
files = []
|
||||
for part in parts:
|
||||
if b"Content-Disposition" not in part:
|
||||
continue
|
||||
try:
|
||||
hdr_end = part.index(b"\r\n\r\n")
|
||||
except ValueError:
|
||||
continue
|
||||
hdrs = part[:hdr_end].decode("latin-1", errors="replace")
|
||||
body = part[hdr_end + 4:]
|
||||
if body.endswith(b"\r\n"):
|
||||
body = body[:-2]
|
||||
m = re.search(r'filename="([^"]*)"', hdrs)
|
||||
if not m:
|
||||
continue
|
||||
name = os.path.basename(m.group(1))
|
||||
try:
|
||||
name = name.encode('latin-1').decode('utf-8')
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
if not name or ".." in name or "/" in name or "\\" in name:
|
||||
continue
|
||||
files.append((name, body, ""))
|
||||
|
||||
if not files:
|
||||
self._json({"ok": False, "error": "Нет файлов"}, 400)
|
||||
return
|
||||
|
||||
class _VMLLM:
|
||||
def complete(self, prompt):
|
||||
import httpx
|
||||
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||
r = httpx.post(
|
||||
os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions"),
|
||||
json={"model": os.environ.get("LLM_MODEL", "gpt-oss-120b"),
|
||||
"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"]
|
||||
|
||||
try:
|
||||
zip_data, _csv = obfuscate_files(files, llm_client=_VMLLM())
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Disposition", 'attachment; filename="drhider_output.zip"')
|
||||
self.send_header("Content-Length", str(len(zip_data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(zip_data)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._json({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _json(self, data, status=200):
|
||||
self.send_response(status)
|
||||
self._send_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
|
||||
|
||||
def _send_cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
TCPServer.allow_reuse_address = True
|
||||
port = int(os.environ.get("PORT", "8767"))
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
print(f"DrHider server on :{port} (NO DB)")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
server.shutdown()
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
flask
|
||||
gunicorn
|
||||
gevent
|
||||
waitress
|
||||
python-docx
|
||||
pdfplumber
|
||||
httpx
|
||||
|
||||
+56
-84
@@ -1,96 +1,68 @@
|
||||
"""
|
||||
DrHider — Managed Flask приложение на платформе Штурвал.
|
||||
|
||||
Приложение создаётся фабрикой create_app().
|
||||
Штурвал запускает Flask самостоятельно (без Dockerfile/gunicorn).
|
||||
|
||||
Роуты разнесены по blueprint'ам:
|
||||
- main_bp: GET / — веб-интерфейс
|
||||
- health_bp: GET /health — проверка живости
|
||||
- api_bp: POST /api/drhider — обфускация документов
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import sys
|
||||
import json
|
||||
import zipfile
|
||||
from flask import Flask, render_template, request, send_file, jsonify
|
||||
from flask import Flask
|
||||
|
||||
# Добавляем корень в sys.path чтобы import drhider работал
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Добавляем корень проекта в sys.path для импорта пакета drhider
|
||||
_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
from drhider import obfuscate_files
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
MAX_BODY = 200 * 1024 * 1024 # 200 MB
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "0.0.31"
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""LLM-клиент для drhider (обнаружение компаний/ФИО)."""
|
||||
def create_app():
|
||||
"""Создать и настроить Flask-приложение.
|
||||
|
||||
def __init__(self):
|
||||
import httpx
|
||||
self._httpx = httpx
|
||||
Штурвал запускает из директории site/:
|
||||
from app import app
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||
r = self._httpx.post(
|
||||
os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions"),
|
||||
json={
|
||||
"model": os.environ.get("LLM_MODEL", "gpt-oss-120b"),
|
||||
"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"]
|
||||
Поэтому routes/ доступен как обычный подпакет.
|
||||
|
||||
Returns:
|
||||
Экземпляр Flask с зарегистрированными blueprint'ами.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Конфигурация
|
||||
app.config["VERSION"] = VERSION
|
||||
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.route("/")
|
||||
def index():
|
||||
return render_template("index.html", version=VERSION)
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "version": VERSION})
|
||||
|
||||
|
||||
@app.route("/api/drhider", methods=["POST"])
|
||||
def api_drhider():
|
||||
"""Обфускация: multipart/form-data с файлами → ZIP."""
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
files = []
|
||||
for f in uploaded:
|
||||
if f.filename:
|
||||
files.append((f.filename, f.read(), f.mimetype or ""))
|
||||
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
try:
|
||||
llm = LLMClient()
|
||||
zip_data, _csv = obfuscate_files(files, llm_client=llm)
|
||||
return send_file(
|
||||
io.BytesIO(zip_data),
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
download_name="drhider_output.zip",
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
# Экземпляр приложения — точка входа для Штурвала
|
||||
app = create_app()
|
||||
|
||||
# Штурвал запускает: cd site && python app.py
|
||||
if __name__ == "__main__":
|
||||
import subprocess
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "gunicorn", "app:app",
|
||||
"--bind", "0.0.0.0:5000",
|
||||
"--worker-class", "gevent",
|
||||
"--workers", "1",
|
||||
"--worker-connections", "1000",
|
||||
"--timeout", "300",
|
||||
])
|
||||
from waitress import serve
|
||||
serve(app, host="0.0.0.0", port=5000, threads=8)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from .main_bp import main_bp
|
||||
from .health_bp import health_bp
|
||||
from .api_bp import api_bp
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
"""Зарегистрировать все blueprint'ы на Flask-приложении.
|
||||
|
||||
Args:
|
||||
app: Экземпляр Flask-приложения
|
||||
"""
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Blueprint: 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
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||
|
||||
from drhider import obfuscate_files, LLMClient
|
||||
from drhider.builder import build_zip, build_mapping_csv
|
||||
from session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count)
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@api_bp.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
"""Upload one file to session."""
|
||||
sid = request.form.get("session", "")
|
||||
if not sid:
|
||||
sid = create_session()
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
return jsonify({"ok": False, "error": "No file"}), 400
|
||||
f = uploaded[0]
|
||||
if not f.filename:
|
||||
return jsonify({"ok": False, "error": "No filename"}), 400
|
||||
ok = add_file(sid, f.filename, f.read())
|
||||
if not ok:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||
|
||||
|
||||
@api_bp.route("/process_stream/<sid>", methods=["GET"])
|
||||
def process_stream(sid):
|
||||
"""SSE: process all session files one by one, streaming per-file progress."""
|
||||
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
|
||||
|
||||
total = len(files)
|
||||
|
||||
def generate():
|
||||
llm = LLMClient()
|
||||
all_results = []
|
||||
all_mapping = {}
|
||||
for idx, (fname, content) in enumerate(files):
|
||||
# Отправляем: начали файл
|
||||
try:
|
||||
yield f"event: start\ndata: {json.dumps({'idx': idx, 'name': fname, 'total': total})}\n\n"
|
||||
except GeneratorExit:
|
||||
return # клиент отключился
|
||||
|
||||
zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm)
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name == "mapping.csv":
|
||||
csv_text = zf.read(name).decode("utf-8")
|
||||
lines = csv_text.strip().split("\n")
|
||||
if len(lines) > 1:
|
||||
for line in lines[1:]:
|
||||
parts = line.split(",", 2)
|
||||
if len(parts) >= 3:
|
||||
all_mapping[parts[1]] = parts[2]
|
||||
elif not name.endswith("/"):
|
||||
all_results.append((name, zf.read(name)))
|
||||
# Отправляем: закончили файл (если клиент ушёл — освобождаем поток)
|
||||
try:
|
||||
yield f"event: done\ndata: {json.dumps({'idx': idx, 'name': fname, 'total': total})}\n\n"
|
||||
except GeneratorExit:
|
||||
return # клиент отключился, НЕ обрабатываем остальные файлы
|
||||
|
||||
csv_str = build_mapping_csv(all_mapping) if all_mapping else ""
|
||||
final_zip = build_zip(all_results)
|
||||
store_result(sid, final_zip)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
yield f"event: complete\ndata: {json.dumps({'total': len(all_results)})}\n\n"
|
||||
|
||||
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 one by one -> ZIP."""
|
||||
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 = LLMClient()
|
||||
all_results = []
|
||||
all_mapping = {}
|
||||
for fname, content in files:
|
||||
zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm)
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name == "mapping.csv":
|
||||
csv_text = zf.read(name).decode("utf-8")
|
||||
lines = csv_text.strip().split("\n")
|
||||
if len(lines) > 1:
|
||||
for line in lines[1:]:
|
||||
parts = line.split(",", 2)
|
||||
if len(parts) >= 3:
|
||||
all_mapping[parts[1]] = parts[2]
|
||||
elif not name.endswith("/"):
|
||||
all_results.append((name, zf.read(name)))
|
||||
csv_str = build_mapping_csv(all_mapping) if all_mapping else ""
|
||||
final_zip = build_zip(all_results) # CSV — отдельно, не в ZIP
|
||||
store_result(sid, final_zip)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
return jsonify({"ok": True, "status": "done", "files": len(all_results)})
|
||||
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")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Blueprint: health-check (GET /health).
|
||||
|
||||
Используется платформой Штурвал для проверки живости приложения.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: health check
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
|
||||
|
||||
@health_bp.route("/health")
|
||||
def health():
|
||||
"""Эндпоинт проверки живости.
|
||||
|
||||
Возвращает JSON с версией приложения.
|
||||
Используется платформой для readiness/liveness probes.
|
||||
|
||||
Returns:
|
||||
{"ok": true, "version": "X.Y.Z"}
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return jsonify({"ok": True, "version": version})
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Blueprint: главная страница (GET /).
|
||||
|
||||
Отдаёт HTML-интерфейс DrHider.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, current_app
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: главная страница
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
def index():
|
||||
"""Главная страница — веб-интерфейс DrHider.
|
||||
|
||||
Передаёт в шаблон текущую версию приложения.
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return render_template("index.html", version=version)
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Хранилище сессий в памяти с автоочисткой по TTL.
|
||||
|
||||
Использование:
|
||||
sid = create_session()
|
||||
add_file(sid, filename, content_bytes)
|
||||
files = get_files(sid)
|
||||
store_result(sid, zip_bytes)
|
||||
zip_data = get_result(sid)
|
||||
cleanup(sid)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Данные сессии
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TTL_SECONDS = 30 * 60 # 30 минут
|
||||
|
||||
_sessions: Dict[str, dict] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _start_timer(sid: str):
|
||||
"""Запустить таймер автоочистки сессии через TTL."""
|
||||
|
||||
def _clean():
|
||||
with _lock:
|
||||
_sessions.pop(sid, None)
|
||||
|
||||
timer = threading.Timer(TTL_SECONDS, _clean)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return timer
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# API
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def create_session() -> str:
|
||||
"""Создать новую сессию.
|
||||
|
||||
Returns:
|
||||
Уникальный идентификатор сессии (UUID).
|
||||
"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
with _lock:
|
||||
_sessions[sid] = {
|
||||
"files": [],
|
||||
"result": None,
|
||||
"timer": _start_timer(sid),
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
def add_file(sid: str, filename: str, content: bytes) -> bool:
|
||||
"""Добавить файл в сессию.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
filename: Имя файла
|
||||
content: Бинарное содержимое
|
||||
|
||||
Returns:
|
||||
True если сессия существует, False если нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
if not s:
|
||||
return False
|
||||
s["files"].append((filename, content))
|
||||
return True
|
||||
|
||||
|
||||
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
|
||||
"""Получить все файлы сессии.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
|
||||
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
|
||||
|
||||
|
||||
def store_result(sid: str, zip_data: bytes) -> bool:
|
||||
"""Сохранить результат обработки.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
zip_data: 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]:
|
||||
"""Получить результат обработки.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
|
||||
Returns:
|
||||
ZIP-архив или None если сессия не найдена/результат не готов.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return s["result"] if s else None
|
||||
|
||||
|
||||
def cleanup(sid: str):
|
||||
"""Удалить сессию."""
|
||||
with _lock:
|
||||
s = _sessions.pop(sid, None)
|
||||
if s and s.get("timer"):
|
||||
s["timer"].cancel()
|
||||
|
||||
|
||||
def store_csv(sid: str, csv_str: str) -> bool:
|
||||
"""Сохранить CSV с таблицей замен.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
csv_str: Строка 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 с таблицей замен.
|
||||
|
||||
Args:
|
||||
sid: Идентификатор сессии
|
||||
|
||||
Returns:
|
||||
Строка CSV или None если нет.
|
||||
"""
|
||||
with _lock:
|
||||
s = _sessions.get(sid)
|
||||
return s.get("csv") if s else None
|
||||
+23
-64
@@ -1,66 +1,25 @@
|
||||
<?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" />
|
||||
<?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.7 KiB After Width: | Height: | Size: 2.8 KiB |
Executable
+25
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
+271
-122
@@ -3,207 +3,356 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>DrHider — обфускация документов</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--brand-primary: #2563eb;
|
||||
--brand-gray: #d1d5db;
|
||||
--brand-grey-light: #f3f4f6;
|
||||
--bg: #f9fafb;
|
||||
--card: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--muted: #888;
|
||||
--brand: #2563eb;
|
||||
--muted: #6b7280;
|
||||
--red: #ef4444;
|
||||
--green: #22c55e;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 14px system-ui, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; }
|
||||
.topbar {
|
||||
background: var(--card); border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 12px;
|
||||
font-weight: 600; font-size: 15px;
|
||||
background: var(--card); border-bottom: 1px solid var(--brand-gray);
|
||||
padding: 0 16px; height: 48px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.content { max-width: 780px; margin: 28px auto; padding: 0 16px; }
|
||||
.topbar img { height: 18px; }
|
||||
.topbar .title { font-weight: 600; font-size: 14px; }
|
||||
.topbar .ver { font-weight: 400; color: var(--muted); font-size: 12px; }
|
||||
.topbar .help { margin-left: auto; font-size: 12px; color: var(--muted); cursor: pointer; text-decoration: underline; }
|
||||
.topbar .help:hover { color: var(--text); }
|
||||
.content { max-width: 820px; margin: 20px auto; padding: 0 12px; }
|
||||
.card {
|
||||
background: var(--card); border-radius: 10px;
|
||||
border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||
overflow: hidden;
|
||||
background: var(--card); border-radius: 12px;
|
||||
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
.card-header {
|
||||
background: #fafafa; padding: 10px 16px;
|
||||
font-weight: 600; font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-body { padding: 16px; }
|
||||
|
||||
.file-input-wrap { margin-bottom: 12px; }
|
||||
.file-input-wrap input[type="file"] { width: 100%; font-size: 14px; }
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
overflow-y: auto; max-height: 60vh;
|
||||
background: var(--brand-grey-light); padding: 10px 12px;
|
||||
font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--brand-gray);
|
||||
}
|
||||
.card-body { padding: 12px; }
|
||||
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
|
||||
.file-input-wrap { margin-bottom: 10px; }
|
||||
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
|
||||
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th {
|
||||
background: #fafafa; text-transform: uppercase; padding: 6px 10px;
|
||||
border-right: 1px solid var(--border); text-align: left;
|
||||
font-weight: 600; font-size: 11px; color: var(--muted);
|
||||
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
|
||||
border-right: 1px solid var(--brand-gray); text-align: left;
|
||||
font-weight: 600; font-size: 10px; color: var(--muted);
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px; border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
|
||||
border-bottom: 1px solid var(--brand-gray);
|
||||
}
|
||||
tr:hover td { background: rgba(37,99,235,.03); }
|
||||
.name-cell { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.num-cell { text-align: right; white-space: nowrap; }
|
||||
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
|
||||
|
||||
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
|
||||
.remove-btn {
|
||||
cursor: pointer; color: #f87171; background: none; border: none;
|
||||
padding: 2px 6px; font-size: 15px; line-height: 1;
|
||||
padding: 2px 4px; font-size: 14px; line-height: 1;
|
||||
}
|
||||
.remove-btn:hover { color: var(--red); }
|
||||
|
||||
.footer-bar {
|
||||
margin-top: 12px; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; color: var(--muted);
|
||||
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
.btn {
|
||||
height: 34px; border-radius: 6px; padding: 0 16px; font-size: 13px;
|
||||
font-family: inherit; cursor: pointer; border: 1px solid var(--border);
|
||||
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
|
||||
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
|
||||
background: var(--card); color: var(--text);
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||||
.btn-primary:hover { opacity: .9; }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 13px; display: none; }
|
||||
.status.success { background: #dcfce7; color: #166534; display: block; }
|
||||
.status.error { background: #fef2f2; color: #991b1b; display: block; }
|
||||
.status.info { background: #eff6ff; color: #1e40af; display: block; }
|
||||
.status { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13px; display: none; }
|
||||
.status.progress { background: #eff6ff; border: 1px solid var(--brand-primary); color: var(--brand-primary); display: block; }
|
||||
.status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; display: block; }
|
||||
.status.error { background: #fef2f2; border: 1px solid var(--red); color: #991b1b; display: block; }
|
||||
.dl-btns { display: none; gap: 8px; margin-top: 8px; }
|
||||
.dl-btns.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
🛡️ DrHider — обфускация документов
|
||||
<span style="font-weight:400;color:var(--muted);font-size:12px;margin-left:auto;">v{{ version }}</span>
|
||||
<img src="/static/logo.svg" alt="Nubes">
|
||||
<span class="title">DrHider</span>
|
||||
<span class="ver">v{{ version }}</span>
|
||||
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">?</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">Выбор файлов</div>
|
||||
<div class="card-header">Обфускация документов</div>
|
||||
<div class="card-body">
|
||||
<p style="color:var(--muted);margin-bottom:12px;font-size:13px;">
|
||||
Загрузите документы (.docx, .xlsx, .pptx, .pdf, .txt).<br>
|
||||
DrHider заменит все персональные данные, реквизиты компаний, телефоны, email на фиктивные значения.
|
||||
<p class="sub">
|
||||
Загрузите документы (.docx, .pdf, .txt, .zip) — получите ZIP с обезличенными копиями.<br>
|
||||
CSV с таблицей замен скачивается отдельно.
|
||||
</p>
|
||||
<div class="file-input-wrap">
|
||||
<input type="file" id="fileInput" multiple accept=".docx,.xlsx,.pptx,.pdf,.txt,.doc,.xls,.ppt">
|
||||
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th style="width:90px;">Размер</th>
|
||||
<th style="width:40px;"></th>
|
||||
<th style="width:80px;">Размер</th>
|
||||
<th style="width:80px;">Статус</th>
|
||||
<th style="width:32px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="fileList">
|
||||
<tr class="empty-row"><td colspan="3">Нет выбранных файлов</td></tr>
|
||||
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer-bar">
|
||||
<span id="fileCount">0 файлов</span>
|
||||
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">
|
||||
🛡️ Обфусцировать
|
||||
</button>
|
||||
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="dl-btns" id="dlBtns">
|
||||
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
|
||||
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const fileCount = document.getElementById('fileCount');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const status = document.getElementById('status');
|
||||
let selectedFiles = [];
|
||||
const fi = document.getElementById('fileInput');
|
||||
const fl = document.getElementById('fileList');
|
||||
const fc = document.getElementById('fileCount');
|
||||
const ub = document.getElementById('uploadBtn');
|
||||
const st = document.getElementById('status');
|
||||
const db = document.getElementById('dlBtns');
|
||||
let sf = [];
|
||||
let currentSid = '';
|
||||
let activeES = null; // активный EventSource
|
||||
let activeXHR = null; // активный XHR
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
function resetAll() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||
currentSid = '';
|
||||
sf = [];
|
||||
fi.value = '';
|
||||
rr();
|
||||
st.className = '';
|
||||
st.textContent = '';
|
||||
db.classList.remove('show');
|
||||
ub.disabled = false;
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
if (selectedFiles.length === 0) {
|
||||
fileList.innerHTML = '<tr class="empty-row"><td colspan="3">Нет выбранных файлов</td></tr>';
|
||||
} else {
|
||||
fileList.innerHTML = selectedFiles.map((f, i) =>
|
||||
'<tr>' +
|
||||
'<td class="name-cell">' + f.name + '</td>' +
|
||||
'<td class="num-cell">' + formatSize(f.size) + '</td>' +
|
||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')">✕</button></td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
// При F5 / закрытии вкладки — обрубить всё
|
||||
window.addEventListener('beforeunload', () => resetAll());
|
||||
|
||||
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
|
||||
|
||||
function rr() {
|
||||
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
|
||||
else { fl.innerHTML = sf.map((f, i) => '<tr id="row-' + i + '"><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">—</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>').join(''); }
|
||||
fc.textContent = sf.length + ' файлов';
|
||||
ub.disabled = sf.length === 0;
|
||||
}
|
||||
|
||||
function rm(i) { sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
|
||||
|
||||
window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
|
||||
fi.addEventListener('change', () => {
|
||||
const incoming = Array.from(fi.files);
|
||||
// Добавляем к существующим, исключая дубликаты по имени+размеру
|
||||
const seen = new Set(sf.map(f => f.name + '|' + f.size));
|
||||
for (const f of incoming) {
|
||||
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
|
||||
}
|
||||
fileCount.textContent = selectedFiles.length + ' файлов';
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
function removeFile(i) {
|
||||
selectedFiles.splice(i, 1);
|
||||
// Пересоздаём FileList через DataTransfer
|
||||
const dt = new DataTransfer();
|
||||
selectedFiles.forEach(f => dt.items.add(f));
|
||||
fileInput.files = dt.files;
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderFiles();
|
||||
// Обновить file input — все файлы из sf
|
||||
const d = new DataTransfer();
|
||||
sf.forEach(f => d.items.add(f));
|
||||
fi.files = d.files;
|
||||
rr();
|
||||
});
|
||||
|
||||
function uploadFiles() {
|
||||
if (selectedFiles.length === 0) return;
|
||||
status.className = 'status info';
|
||||
status.textContent = 'Обфускация...';
|
||||
uploadBtn.disabled = true;
|
||||
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(f => formData.append('files', f));
|
||||
async function uploadFiles() {
|
||||
if (sf.length === 0) return;
|
||||
// Сохранить список до очистки
|
||||
const files = sf.slice();
|
||||
// Обрубить всё что могло остаться от предыдущего раза
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||
currentSid = '';
|
||||
st.className = '';
|
||||
st.textContent = '';
|
||||
db.classList.remove('show');
|
||||
ub.disabled = true;
|
||||
const total = files.length;
|
||||
|
||||
fetch('/api/drhider', { method: 'POST', body: formData })
|
||||
.then(res => {
|
||||
if (!res.ok) return res.json().then(e => { throw new Error(e.error || 'Ошибка сервера'); });
|
||||
const disp = res.headers.get('Content-Disposition') || '';
|
||||
const nameMatch = disp.match(/filename="?(.+?)"?$/);
|
||||
const filename = nameMatch ? nameMatch[1] : 'drhider_output.zip';
|
||||
return res.blob().then(blob => ({ blob, filename }));
|
||||
// Фаза 1: загрузка (XMLHttpRequest — прогресс в строке)
|
||||
for (let i = 0; i < total; i++) {
|
||||
const f = files[i], n = i + 1;
|
||||
st.className = 'status progress';
|
||||
st.textContent = 'Загрузка ' + n + '/' + total + ': ' + f.name;
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
await new Promise((resolve, reject) => {
|
||||
const fd = new FormData();
|
||||
fd.append('files', f, f.name);
|
||||
if (currentSid) fd.append('session', currentSid);
|
||||
const url = '/api/upload?_=' + Date.now();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
xhr.timeout = 30000;
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round(e.loaded / e.total * 100);
|
||||
ss(i, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
|
||||
}
|
||||
};
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.ok) {
|
||||
const elapsed = (performance.now() - t0) / 1000;
|
||||
const speed = f.size / elapsed;
|
||||
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
|
||||
currentSid = data.session;
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(data.error));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('HTTP ' + xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут 30с')); };
|
||||
activeXHR = xhr;
|
||||
xhr.send(fd);
|
||||
});
|
||||
} catch (err) {
|
||||
ss(i, '<span style="color:#ef4444">✗</span>');
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка загрузки: ' + err.message;
|
||||
ub.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
|
||||
for (let i = 0; i < total; i++) ss(i, '<span style="color:#2563eb">⏳</span>');
|
||||
const fileTimers = {}; // idx -> performance.now()
|
||||
const fileIntervals = {}; // idx -> setInterval id
|
||||
st.className = 'status progress';
|
||||
st.textContent = 'Обработка...';
|
||||
const t0 = performance.now();
|
||||
const ptimer = setInterval(() => {
|
||||
const sec = Math.round((performance.now() - t0) / 1000);
|
||||
st.textContent = 'Обработка... ' + sec + 'с';
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||
activeES.addEventListener('start', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
fileTimers[d.idx] = performance.now();
|
||||
fileIntervals[d.idx] = setInterval(function() {
|
||||
const elapsed = ((performance.now() - fileTimers[d.idx]) / 1000).toFixed(1);
|
||||
ss(d.idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
|
||||
}, 200);
|
||||
});
|
||||
activeES.addEventListener('done', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
clearInterval(fileIntervals[d.idx]);
|
||||
const elapsed = ((performance.now() - (fileTimers[d.idx] || performance.now())) / 1000).toFixed(1);
|
||||
ss(d.idx, '<span style="color:#22c55e">✓ ' + elapsed + 'с</span>');
|
||||
});
|
||||
activeES.addEventListener('complete', function(e) {
|
||||
activeES.close();
|
||||
activeES = null;
|
||||
clearInterval(ptimer);
|
||||
const d = JSON.parse(e.data);
|
||||
st.className = 'status done';
|
||||
st.textContent = '✅ Обработано ' + d.total + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с';
|
||||
db.classList.add('show');
|
||||
resolve();
|
||||
});
|
||||
activeES.onerror = function() {
|
||||
activeES.close();
|
||||
activeES = null;
|
||||
clearInterval(ptimer);
|
||||
reject(new Error('SSE connection failed'));
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
clearInterval(ptimer);
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка: ' + err.message;
|
||||
}
|
||||
ub.disabled = false;
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
if (!currentSid) return;
|
||||
fetch('/api/download/' + currentSid)
|
||||
.then(r => {
|
||||
const disp = r.headers.get('Content-Disposition');
|
||||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||||
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
|
||||
})
|
||||
.then(({ blob, filename }) => {
|
||||
status.className = 'status success';
|
||||
status.textContent = 'Готово! Скачивание началось.';
|
||||
const url = URL.createObjectURL(blob);
|
||||
.then(([blob, fname]) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
uploadBtn.disabled = false;
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadCsv() {
|
||||
if (!currentSid) return;
|
||||
fetch('/api/csv/' + currentSid)
|
||||
.then(r => {
|
||||
const disp = r.headers.get('Content-Disposition');
|
||||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||||
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
|
||||
})
|
||||
.catch(err => {
|
||||
status.className = 'status error';
|
||||
status.textContent = 'Ошибка: ' + err.message;
|
||||
uploadBtn.disabled = false;
|
||||
.then(([blob, fname]) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
|
||||
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
||||
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6">
|
||||
Загрузите документы (.docx, .pdf, .txt, .zip) — получите ZIP-архив с обезличенными копиями.
|
||||
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные коды.
|
||||
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
|
||||
</p>
|
||||
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Тесты builder.py — создание ZIP и mapping.csv.
|
||||
|
||||
Запуск: python3 tests/test_builder.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from drhider.builder import build_zip, build_mapping_csv
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test build_zip
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== build_zip ===")
|
||||
|
||||
# 1. Базовый ZIP с одним файлом
|
||||
zip_data = build_zip([("test.md", b"content")])
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
names = zf.namelist()
|
||||
check("один файл в ZIP", len(names) == 1)
|
||||
check("имя test.md", "test.md" in names)
|
||||
check("контент верный", zf.read("test.md") == b"content")
|
||||
|
||||
# 2. ZIP с несколькими файлами
|
||||
zip_data = build_zip([
|
||||
("договор_obfuscated.md", "# Договор\nТекст".encode("utf-8")),
|
||||
("спецификация_obfuscated.md", "| Услуга | Цена |".encode("utf-8")),
|
||||
])
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
names = zf.namelist()
|
||||
check("два файла", len(names) == 2)
|
||||
check("mapping.csv нет", "mapping.csv" not in names)
|
||||
check("договор читается", "Договор" in zf.read("договор_obfuscated.md").decode("utf-8"))
|
||||
|
||||
# 3. ZIP с mapping.csv
|
||||
zip_data = build_zip(
|
||||
[("file.md", b"data")],
|
||||
mapping_csv="тип,оригинал,замена\nperson,Иванов,Иванов_0001\n"
|
||||
)
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
names = zf.namelist()
|
||||
check("mapping.csv есть", "mapping.csv" in names)
|
||||
csv_content = zf.read("mapping.csv").decode("utf-8")
|
||||
check("mapping с BOM", csv_content.startswith('\ufeff'))
|
||||
check("данные в mapping", "Иванов_0001" in csv_content)
|
||||
|
||||
# 4. ZIP с кириллицей в именах
|
||||
zip_data = build_zip([("договор_№123.md", "текст".encode("utf-8"))])
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
check("кириллическое имя читается", "договор_№123.md" in zf.namelist())
|
||||
|
||||
# 5. ZIP с бинарными данными
|
||||
binary = bytes(range(256))
|
||||
zip_data = build_zip([("binary.bin", binary)])
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
check("бинарный файл сохранён", zf.read("binary.bin") == binary)
|
||||
|
||||
# 6. Пустой mapping
|
||||
zip_data = build_zip([("f.md", b"x")], mapping_csv="")
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
check("пустой mapping не добавляется", "mapping.csv" not in zf.namelist())
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test build_mapping_csv
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== build_mapping_csv ===")
|
||||
|
||||
# 1. Простой mapping
|
||||
mapping = {"Иванов И.И.": "Иванов_0001"}
|
||||
csv_str = build_mapping_csv(mapping)
|
||||
check("заголовок есть", csv_str.startswith("тип_данных,оригинал,замена"))
|
||||
check("данные есть", "Иванов_0001" in csv_str)
|
||||
|
||||
# 2. Пустой mapping
|
||||
csv_str = build_mapping_csv({})
|
||||
lines = csv_str.strip().split("\n")
|
||||
check("пустой — только заголовок", len(lines) == 1)
|
||||
|
||||
# 3. Сортировка
|
||||
mapping = {"Б": "Б_0001", "А": "А_0001"}
|
||||
csv_str = build_mapping_csv(mapping)
|
||||
check("сортировка А перед Б", csv_str.index("А_0001") < csv_str.index("Б_0001"))
|
||||
|
||||
# 4. Кавычки в значениях
|
||||
mapping = {'ООО "Ромашка"': "ООО_Технология_0001"}
|
||||
csv_str = build_mapping_csv(mapping)
|
||||
check("кавычки экранированы", 'ООО "Ромашка"' in csv_str or 'ООО ""Ромашка""' in csv_str)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test: интеграция build_zip + build_mapping_csv
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== build_zip + build_mapping_csv ===")
|
||||
|
||||
mapping = {"Иванов И.И.": "Иванов_0001", "ООО Ромашка": "ООО_Технология_0001"}
|
||||
csv = build_mapping_csv(mapping)
|
||||
zip_data = build_zip([("doc.md", "# Договор с Иванов_0001".encode("utf-8"))], mapping_csv=csv)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
check("два файла в ZIP", len(zf.namelist()) == 2)
|
||||
doc = zf.read("doc.md").decode("utf-8")
|
||||
check("doc с заменой", "Иванов_0001" in doc)
|
||||
csv_content = zf.read("mapping.csv").decode("utf-8")
|
||||
check("CSV с обеими заменами", "Иванов_0001" in csv_content and "ООО_Технология_0001" in csv_content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||
if failed > 0:
|
||||
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Тесты extractor.py — извлечение текста, ZIP-распаковка.
|
||||
|
||||
Запуск: python3 tests/test_extractor.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from drhider.extractor import extract_text, expand_zips, docx_to_markdown, pdf_to_markdown
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# extract_text
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== extract_text ===")
|
||||
|
||||
# 1. .txt
|
||||
text = extract_text("test.txt", "Просто текст".encode("utf-8"))
|
||||
check(".txt — текст извлечён", text == "Просто текст")
|
||||
|
||||
# 2. .txt с кириллицей
|
||||
text = extract_text("документ.txt", "Привет мир".encode("utf-8"))
|
||||
check(".txt — кириллица", "Привет" in text)
|
||||
|
||||
# 3. .doc (бинарный — пропускается)
|
||||
text = extract_text("старый.doc", b"\xD0\xCF\x11\xE0\x00" * 10)
|
||||
check(".doc — заглушка", "not supported" in text.lower() or "binary" in text.lower())
|
||||
|
||||
# 4. Неизвестное расширение → UTF-8
|
||||
text = extract_text("file.xyz", "hello".encode("utf-8"))
|
||||
check(".xyz — как текст", text == "hello")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# expand_zips
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== expand_zips ===")
|
||||
|
||||
# 1. Не-ZIP файлы проходят без изменений
|
||||
files = [("test.txt", b"content", "text/plain")]
|
||||
result = expand_zips(files)
|
||||
check("не-ZIP — без изменений", len(result) == 1 and result[0][0] == "test.txt")
|
||||
|
||||
# 2. ZIP распаковывается
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("inner.txt", b"hello")
|
||||
result = expand_zips([("archive.zip", buf.getvalue(), "application/zip")])
|
||||
check("ZIP — распакован", len(result) == 1 and result[0][0] == "inner.txt")
|
||||
check("ZIP — контент верный", result[0][1] == b"hello")
|
||||
|
||||
# 3. ZIP с несколькими файлами
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("a.txt", b"aaa")
|
||||
zf.writestr("b.txt", b"bbb")
|
||||
result = expand_zips([("multi.zip", buf.getvalue(), "")])
|
||||
check("ZIP — два файла", len(result) == 2)
|
||||
|
||||
# 4. Path traversal защита
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("../../etc/passwd", b"bad")
|
||||
result = expand_zips([("evil.zip", buf.getvalue(), "")])
|
||||
check("path traversal — заблокирован", all("/" not in r[0] and ".." not in r[0] for r in result))
|
||||
|
||||
# 5. Директории пропускаются
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("dir/", b"")
|
||||
zf.writestr("dir/file.txt", b"ok")
|
||||
result = expand_zips([("dirs.zip", buf.getvalue(), "")])
|
||||
check("директории — пропущены", len(result) == 1)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# docx_to_markdown
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== docx_to_markdown ===")
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
# Создаём тестовый DOCX
|
||||
doc = Document()
|
||||
doc.add_heading("Заголовок", level=1)
|
||||
p = doc.add_paragraph("Обычный текст")
|
||||
p.add_run(" жирный").bold = True
|
||||
doc.add_paragraph("")
|
||||
|
||||
# Таблица
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.rows[0].cells[0].text = "A"
|
||||
table.rows[0].cells[1].text = "B"
|
||||
table.rows[1].cells[0].text = "1"
|
||||
table.rows[1].cells[1].text = "2"
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
md = docx_to_markdown(buf.getvalue())
|
||||
|
||||
check("DOCX — заголовок #", "# Заголовок" in md)
|
||||
check("DOCX — жирный **", "жирный**" in md and "**" in md)
|
||||
check("DOCX — таблица", "| A | B |" in md)
|
||||
check("DOCX — разделитель таблицы", "---|---|" in md)
|
||||
check("DOCX — данные таблицы", "| 1 | 2 |" in md)
|
||||
|
||||
except ImportError:
|
||||
print(" ⚠️ python-docx не установлен — тесты DOCX пропущены")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Итог
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||
if failed > 0:
|
||||
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Тесты replacer.py — apply_replacements.
|
||||
|
||||
Запуск: python3 tests/test_replacer.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from drhider.replacer import apply_replacements
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# apply_replacements
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== apply_replacements ===")
|
||||
|
||||
# 1. Простая замена
|
||||
text = "Директор Иванов И.И. подписал"
|
||||
mapping = {"Иванов И.И.": "Иванов_0001"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("простая замена", result == "Директор Иванов_0001 подписал")
|
||||
|
||||
# 2. Длинное before короткого
|
||||
text = "ИНН 123456789012 и ИНН 1234567890"
|
||||
mapping = {"ИНН 123456789012": "ИНН_0001", "ИНН 1234567890": "ИНН_0002"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("длинное раньше короткого", "ИНН_0001" in result and "ИНН_0002" in result)
|
||||
check("12-значный не задет 10-значным", result == "ИНН_0001 и ИНН_0002")
|
||||
|
||||
# 3. Границы слова
|
||||
text = "Email: test@mail.ru написать"
|
||||
mapping = {"test@mail.ru": "email_0001@fake"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("email заменён", "email_0001@fake" in result)
|
||||
|
||||
# 4. Множественные замены
|
||||
text = "Иванов И.И. и Петров А.С. подписали"
|
||||
mapping = {"Иванов И.И.": "Иванов_0001", "Петров А.С.": "Петров_0002"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("две замены", "Иванов_0001" in result and "Петров_0002" in result)
|
||||
|
||||
# 5. Спецсимволы в ключе
|
||||
text = 'ООО "Ромашка" заключила'
|
||||
mapping = {'ООО "Ромашка"': "ООО_Технология_0001"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("кавычки в ключе", "ООО_Технология_0001" in result)
|
||||
|
||||
# 6. Кириллица
|
||||
text = "Москва, ул. Ленина, д. 42"
|
||||
mapping = {"Москва": "Москва_0001", "Ленина": "Ленина_0015"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("кириллица заменена", "Москва_0001" in result and "Ленина_0015" in result)
|
||||
|
||||
# 7. Частичное совпадение не заменяется
|
||||
text = "Ивановский проспект"
|
||||
mapping = {"Иванов": "Иванов_0001"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("частичное совпадение — не заменено", result == "Ивановский проспект")
|
||||
|
||||
# 8. Пустой mapping
|
||||
text = "ничего не меняется"
|
||||
result = apply_replacements(text, {}, [])
|
||||
check("пустой mapping", result == text)
|
||||
|
||||
# 9. Markdown текст
|
||||
text = "**ООО Ромашка** заключила с **Иванов И.И.**"
|
||||
mapping = {"ООО Ромашка": "ООО_Технология_0001", "Иванов И.И.": "Иванов_0002"}
|
||||
keys = sorted(mapping.keys(), key=len, reverse=True)
|
||||
result = apply_replacements(text, mapping, keys)
|
||||
check("Markdown bold сохранён", "**ООО_Технология_0001**" in result)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||
if failed > 0:
|
||||
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Тесты scanner.py — scan_regex, _next_token.
|
||||
|
||||
Запуск: python3 tests/test_scanner.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from drhider.scanner import scan_regex, _next_token
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _next_token
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== _next_token ===")
|
||||
|
||||
counters = {}
|
||||
|
||||
# 1. Базовый токен
|
||||
token = _next_token("person_name", counters)
|
||||
check("person → Иванов/Петров/etc", token.endswith("_0001"))
|
||||
check("счётчик увеличился", counters.get("person_Иванов", 0) == 1 or any(v == 1 for v in counters.values()))
|
||||
|
||||
# 2. Второй токен того же типа — новый номер
|
||||
token2 = _next_token("person_name", counters)
|
||||
check("второй person — новый счётчик", any(v >= 1 for v in counters.values()))
|
||||
|
||||
# 3. Разные типы — разные счётчики
|
||||
token3 = _next_token("company", counters)
|
||||
check("company ≠ person", token3 != token)
|
||||
|
||||
# 4. Неизвестный тип → fallback
|
||||
counters2 = {}
|
||||
token4 = _next_token("unknown_xyz", counters2)
|
||||
check("неизвестный тип — Данные_0001", token4.startswith("Данные_") and "_0001" in token4)
|
||||
|
||||
# 5. phone
|
||||
counters3 = {}
|
||||
token5 = _next_token("phone", counters3)
|
||||
check("phone — +7_000_000_0001", token5 == "+7_000_000_0001")
|
||||
|
||||
# 6. address
|
||||
counters4 = {}
|
||||
token6 = _next_token("address", counters4)
|
||||
check("address — город_0001", token6.endswith("_0001") and len(token6) > 5)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# scan_regex
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== scan_regex ===")
|
||||
|
||||
# 1. Телефон
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("Звоните +7 (495) 789-41-35", mapping, counters)
|
||||
check("телефон найден", len(mapping) == 1)
|
||||
check("телефон — токен", list(mapping.values())[0].startswith("+7_000_000_"))
|
||||
|
||||
# 2. Email
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("Пишите на info@nubes.ru", mapping, counters)
|
||||
check("email найден", len(mapping) == 1)
|
||||
check("email — токен", list(mapping.values())[0].startswith("email_"))
|
||||
|
||||
# 3. ИНН
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("ИНН 9706005293", mapping, counters)
|
||||
check("ИНН найден", len(mapping) == 1)
|
||||
check("ИНН — токен", list(mapping.values())[0].startswith("ИНН_"))
|
||||
|
||||
# 4. Компания
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex('ООО "Ромашка" заключила договор', mapping, counters)
|
||||
check("компания найдена", len(mapping) == 1)
|
||||
check("компания — токен", "_" in list(mapping.values())[0])
|
||||
|
||||
# 5. ФИО
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("Директор Иванов И.И. подписал", mapping, counters)
|
||||
check("ФИО найдено", len(mapping) == 1)
|
||||
check("ФИО — токен", "_" in list(mapping.values())[0])
|
||||
|
||||
# 6. Множественные сущности
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759", mapping, counters)
|
||||
check("три сущности", len(mapping) == 3)
|
||||
|
||||
# 7. Дубликаты не перезаписываются
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("+7 (495) 789-41-35 и ещё +7 (495) 789-41-35", mapping, counters)
|
||||
check("дубликат — одна запись", len(mapping) == 1)
|
||||
|
||||
# 8. Согласованность: один счётчик
|
||||
mapping = {}
|
||||
counters = {}
|
||||
scan_regex("ИНН 9706005293 и КПП 772401001", mapping, counters)
|
||||
inn_val = mapping["ИНН 9706005293"]
|
||||
kpp_val = mapping["КПП 772401001"]
|
||||
check("ИНН и КПП разные токены", inn_val != kpp_val)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||
if failed > 0:
|
||||
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Тесты POST /api/upload — загрузка файлов в сессию.
|
||||
|
||||
Запуск:
|
||||
python3 tests/test_upload.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import gc
|
||||
|
||||
# Настраиваем пути
|
||||
_site = os.path.join(os.path.dirname(__file__), "..", "site")
|
||||
sys.path.insert(0, _site)
|
||||
sys.path.insert(0, os.path.dirname(_site)) # project root for drhider
|
||||
|
||||
from flask import Flask
|
||||
from routes.api_bp import api_bp
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _new_client():
|
||||
"""Создать свежий тестовый клиент."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_bp)
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _get_rss():
|
||||
"""RSS процесса в байтах (Linux)."""
|
||||
try:
|
||||
with open("/proc/self/statm", "r") as f:
|
||||
parts = f.read().split()
|
||||
return int(parts[1]) * 4096
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Базовые тесты
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_no_file():
|
||||
"""POST без файла → 400."""
|
||||
c = _new_client()
|
||||
rv = c.post("/api/upload")
|
||||
assert rv.status_code == 400
|
||||
data = json.loads(rv.data)
|
||||
assert data["ok"] is False
|
||||
assert "No file" in data["error"]
|
||||
|
||||
|
||||
def test_no_filename():
|
||||
"""POST с файлом без имени → 400."""
|
||||
c = _new_client()
|
||||
data = {"files": (io.BytesIO(b"hello"), "")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 400
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is False
|
||||
assert "filename" in resp["error"].lower()
|
||||
|
||||
|
||||
def test_single_file():
|
||||
"""POST с одним файлом → 200, сессия + count=1."""
|
||||
c = _new_client()
|
||||
data = {"files": (io.BytesIO(b"hello world"), "test.txt")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert len(resp["session"]) == 12
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
def test_two_files_same_session():
|
||||
"""Два файла в одну сессию → count=2."""
|
||||
c = _new_client()
|
||||
data1 = {"files": (io.BytesIO(b"first"), "file1.txt")}
|
||||
rv1 = c.post("/api/upload", data=data1, content_type="multipart/form-data")
|
||||
assert rv1.status_code == 200
|
||||
sid = json.loads(rv1.data)["session"]
|
||||
|
||||
data2 = {
|
||||
"files": (io.BytesIO(b"second"), "file2.txt"),
|
||||
"session": sid,
|
||||
}
|
||||
rv2 = c.post("/api/upload", data=data2, content_type="multipart/form-data")
|
||||
assert rv2.status_code == 200
|
||||
resp2 = json.loads(rv2.data)
|
||||
assert resp2["session"] == sid
|
||||
assert resp2["count"] == 2
|
||||
|
||||
|
||||
def test_fake_session_404():
|
||||
"""Несуществующая сессия → 404."""
|
||||
c = _new_client()
|
||||
data = {
|
||||
"files": (io.BytesIO(b"data"), "f.txt"),
|
||||
"session": "deadbeef1234",
|
||||
}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 404
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is False
|
||||
|
||||
|
||||
def test_empty_file():
|
||||
"""Пустой файл (0 байт) → 200."""
|
||||
c = _new_client()
|
||||
data = {"files": (io.BytesIO(b""), "empty.txt")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
def test_cyrillic_filename():
|
||||
"""Кириллическое имя файла → 200."""
|
||||
c = _new_client()
|
||||
content = "кириллица".encode("utf-8")
|
||||
data = {"files": (io.BytesIO(content), "договор_№123.txt")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
def test_content_type_json():
|
||||
"""Ответ — application/json."""
|
||||
c = _new_client()
|
||||
data = {"files": (io.BytesIO(b"x"), "f.txt")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
assert "application/json" in rv.content_type
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Большие файлы
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_large_10mb():
|
||||
"""Файл 10 MB — загружается без ошибок."""
|
||||
c = _new_client()
|
||||
size = 10 * 1024 * 1024
|
||||
big_data = b"A" * size
|
||||
data = {"files": (io.BytesIO(big_data), "big_10mb.bin")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
def test_large_50mb():
|
||||
"""Файл 50 MB — загружается без ошибок."""
|
||||
c = _new_client()
|
||||
size = 50 * 1024 * 1024
|
||||
big_data = b"B" * size
|
||||
data = {"files": (io.BytesIO(big_data), "big_50mb.bin")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Много мелких файлов
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_many_small_files():
|
||||
"""100 файлов по 1 KB в одну сессию."""
|
||||
c = _new_client()
|
||||
data1 = {"files": (io.BytesIO(b"x" * 1024), "file_000.txt")}
|
||||
rv1 = c.post("/api/upload", data=data1, content_type="multipart/form-data")
|
||||
sid = json.loads(rv1.data)["session"]
|
||||
|
||||
for i in range(1, 100):
|
||||
data = {
|
||||
"files": (io.BytesIO(b"y" * 1024), f"file_{i:03d}.txt"),
|
||||
"session": sid,
|
||||
}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["session"] == sid
|
||||
assert resp["count"] == i + 1
|
||||
|
||||
assert resp["count"] == 100
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Утечка памяти
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_memory_after_large_files():
|
||||
"""После загрузки больших файлов память не течёт."""
|
||||
c = _new_client()
|
||||
gc.collect()
|
||||
mem_before = _get_rss()
|
||||
|
||||
for i in range(10):
|
||||
data = {"files": (io.BytesIO(b"M" * (1024 * 1024)), f"memtest_{i}.bin")}
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
assert rv.status_code == 200
|
||||
|
||||
gc.collect()
|
||||
mem_after = _get_rss()
|
||||
diff_mb = (mem_after - mem_before) / (1024 * 1024)
|
||||
assert diff_mb < 50, f"Memory grew {diff_mb:.1f} MB, expected < 50 MB"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Бенчмарк скорости
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_upload_speed():
|
||||
"""1 MB файл — загрузка < 500 ms."""
|
||||
c = _new_client()
|
||||
data = {"files": (io.BytesIO(b"S" * (1024 * 1024)), "speed.bin")}
|
||||
|
||||
start = time.time()
|
||||
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert elapsed < 0.5, f"Upload took {elapsed:.3f}s, expected < 0.5s"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Основной блок
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
passed = 0
|
||||
failed = 0
|
||||
tests = [
|
||||
test_no_file,
|
||||
test_no_filename,
|
||||
test_single_file,
|
||||
test_two_files_same_session,
|
||||
test_fake_session_404,
|
||||
test_empty_file,
|
||||
test_cyrillic_filename,
|
||||
test_content_type_json,
|
||||
test_large_10mb,
|
||||
test_large_50mb,
|
||||
test_many_small_files,
|
||||
test_memory_after_large_files,
|
||||
test_upload_speed,
|
||||
]
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" ✅ {test.__name__}")
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" ❌ {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {len(tests)}")
|
||||
if failed == 0:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
else:
|
||||
print("❌ ЕСТЬ ПРОВАЛЫ")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Тесты нативного ZIP: unzip → buildZip.
|
||||
|
||||
Эмулирует JS-логику:
|
||||
1. Сервер возвращает ZIP с .md + mapping.csv
|
||||
2. unzip() распаковывает
|
||||
3. buildZip() перепаковывает без mapping.csv
|
||||
4. Результат должен быть валидным ZIP
|
||||
|
||||
Запуск: python3 tests/test_zip.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 1: roundtrip — server ZIP → extract → rebuild → verify
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 1: unzip + buildZip roundtrip ===")
|
||||
|
||||
# Создаём ZIP как сервер
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("договор_obfuscated.md", "# Заголовок\nТекст договора с Иванов_0001.")
|
||||
zf.writestr("спецификация_obfuscated.md", "| Услуга | Цена |\n|---|---|\n| Хостинг | 1000 |")
|
||||
zf.writestr("mapping.csv", "тип,оригинал,замена\nperson,Иванов И.И.,Иванов_0001\n")
|
||||
|
||||
server_zip = buf.getvalue()
|
||||
|
||||
# Распаковываем (эмуляция unzip)
|
||||
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
|
||||
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||
|
||||
check("3 файла в ZIP", len(extracted) == 3)
|
||||
check("mapping.csv есть", "mapping.csv" in extracted)
|
||||
check("договор есть", "договор_obfuscated.md" in extracted)
|
||||
|
||||
# Отделяем mapping от файлов
|
||||
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||
mapping_data = extracted.get("mapping.csv", b"").decode("utf-8")
|
||||
|
||||
check("mapping читается", "Иванов_0001" in mapping_data)
|
||||
check("договор читается", "Иванов_0001" in md_files["договор_obfuscated.md"].decode("utf-8"))
|
||||
|
||||
# Перепаковываем без mapping.csv (эмуляция buildZip)
|
||||
buf2 = io.BytesIO()
|
||||
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||
for name, data in md_files.items():
|
||||
zf.writestr(name, data)
|
||||
rebuilt_zip = buf2.getvalue()
|
||||
|
||||
# Проверяем перепакованный ZIP
|
||||
with zipfile.ZipFile(io.BytesIO(rebuilt_zip)) as zf:
|
||||
names = zf.namelist()
|
||||
check("mapping.csv НЕТ в перепакованном", "mapping.csv" not in names)
|
||||
check("договор есть в перепакованном", "договор_obfuscated.md" in names)
|
||||
check("спецификация есть в перепакованном", "спецификация_obfuscated.md" in names)
|
||||
content = zf.read("договор_obfuscated.md").decode("utf-8")
|
||||
check("содержимое договора сохранено", "Иванов_0001" in content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 2: ZIP с одним файлом
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 2: один файл без mapping ===")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("file.md", "content")
|
||||
|
||||
server_zip = buf.getvalue()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
|
||||
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||
|
||||
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||
|
||||
buf2 = io.BytesIO()
|
||||
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||
for name, data in md_files.items():
|
||||
zf.writestr(name, data)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
|
||||
check("один файл", len(zf.namelist()) == 1)
|
||||
check("контент сохранён", zf.read("file.md").decode("utf-8") == "content")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 3: .doc файл (binary, не меняется)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 3: бинарный .doc ===")
|
||||
|
||||
binary_data = bytes([0xD0, 0xCF, 0x11, 0xE0]) + b"\x00" * 100 # OLE2 magic
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("file_obfuscated.doc", binary_data)
|
||||
zf.writestr("mapping.csv", "тип,оригинал,замена\n")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
|
||||
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||
|
||||
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||
|
||||
buf2 = io.BytesIO()
|
||||
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||
for name, data in md_files.items():
|
||||
zf.writestr(name, data)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
|
||||
check(".doc сохранён", "file_obfuscated.doc" in zf.namelist())
|
||||
content = zf.read("file_obfuscated.doc")
|
||||
check("размер сохранён", len(content) == len(binary_data))
|
||||
check("данные сохранены", content == binary_data)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 4: CSV с кириллицей и кавычками
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 4: CSV с кириллицей ===")
|
||||
|
||||
csv_content = 'тип,оригинал,замена\ncompany,"ООО ""НУБЕС""",ООО_Технология_0001\nperson,Иванов И.И.,Иванов_0002\n'
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("test.md", "text")
|
||||
zf.writestr("mapping.csv", csv_content)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
|
||||
mapping = zf.read("mapping.csv").decode("utf-8")
|
||||
|
||||
check("BOM есть (\\ufeff)", mapping.startswith('\ufeff') or mapping.startswith('тип'))
|
||||
check("НУБЕС в mapping", "НУБЕС" in mapping)
|
||||
check("Иванов в mapping", "Иванов" in mapping)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 5: множественные mapping.csv объединяются
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 5: объединение mapping.csv ===")
|
||||
|
||||
all_mappings = []
|
||||
for csv_text in [
|
||||
"тип,оригинал,замена\nperson,Иванов,Иванов_0001\n",
|
||||
"тип,оригинал,замена\nperson,Петров,Петров_0002\ncompany,Ромашка,Ромашка_0001\n",
|
||||
]:
|
||||
lines = csv_text.split("\n")
|
||||
if len(all_mappings) == 0:
|
||||
all_mappings.append(lines[0]) # заголовок
|
||||
all_mappings.append(lines[1]) # первая строка
|
||||
else:
|
||||
all_mappings.append(lines[1]) # только данные, без заголовка
|
||||
if len(lines) > 2 and lines[2]:
|
||||
all_mappings.append(lines[2])
|
||||
|
||||
result = "\n".join(all_mappings)
|
||||
check("заголовок один", result.count("тип,оригинал,замена") == 1)
|
||||
check("Иванов есть", "Иванов" in result)
|
||||
check("Петров есть", "Петров" in result)
|
||||
check("Ромашка есть", "Ромашка" in result)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Test 6: точные смещения в local file header (баг start+18)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print("\n=== Тест 6: валидация смещений local file header ===")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as zf:
|
||||
zf.writestr("test.md", "Hello World")
|
||||
raw = buf.getvalue()
|
||||
|
||||
import struct
|
||||
sig_pos = raw.find(b'PK\x03\x04')
|
||||
check("сигнатура найдена", sig_pos >= 0)
|
||||
|
||||
def u16(off): return struct.unpack_from('<H', raw, off)[0]
|
||||
def u32(off): return struct.unpack_from('<I', raw, off)[0]
|
||||
|
||||
start = sig_pos
|
||||
check("signature = 0x04034b50", u32(start) == 0x04034b50)
|
||||
|
||||
name_len = u16(start + 26)
|
||||
extra_len = u16(start + 28)
|
||||
comp_size = u32(start + 18) # смещение которое было сломано
|
||||
uncomp_size = u32(start + 22)
|
||||
|
||||
check("compressed size > 0", comp_size > 0)
|
||||
check("compressed == uncompressed", comp_size == uncomp_size)
|
||||
check("filename length", name_len == len("test.md"))
|
||||
|
||||
name_start = start + 30
|
||||
name = raw[name_start:name_start + name_len].decode('ascii')
|
||||
check("имя файла", name == "test.md")
|
||||
|
||||
data_start = name_start + name_len + extra_len
|
||||
data = raw[data_start:data_start + comp_size].decode('ascii')
|
||||
check("данные файла", data == "Hello World")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Итог
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||
if failed > 0:
|
||||
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
Reference in New Issue
Block a user