diff --git a/History/2026-08-19-file-time.md b/History/2026-08-19-file-time.md
new file mode 100644
index 0000000..5144623
--- /dev/null
+++ b/History/2026-08-19-file-time.md
@@ -0,0 +1,40 @@
+# v0.0.41 — время на конкретный файл в колонке Статус — 2026-08-19
+
+**Дата:** 2026-08-19
+**Версия:** 0.0.40 → 0.0.41
+
+---
+
+## Суть
+
+В колонке «Статус» таблицы у всех файлов показывалось ~одинаковое ОБЩЕЕ время.
+Причина: фронт считал время как разницу `start`→`done`, а LLM — общий для всех
+файлов (в проходе 1) — попадал в интервал КАЖДОГО файла.
+
+Теперь бэк считает время на КОНКРЕТНЫЙ файл (extract_text + regex + замена,
+БЕЗ общего LLM) и передаёт его в `done`-событии.
+
+## Изменения
+
+### `drhider/obfuscator.py`
+- `import time` добавлен.
+- Заведён `file_times = [0.0] * total`.
+- Проход 1: `file_times[i] += time.time() - t0` вокруг extract_text + regex.
+- Проход 2: `file_times[i] += time.time() - t0` вокруг замены.
+- `progress_cb` расширен до `(phase, idx, total, fname, elapsed)`; в `done`
+ передаётся `round(file_times[i], 2)`.
+
+### `site/routes/api_bp.py`
+- `progress(phase, idx, total_, name, elapsed)`; в SSE `done` добавлен `elapsed`.
+
+### `site/templates/index.html`
+- В `done`-обработчике: если `d.elapsed > 0` — показать `d.elapsed.toFixed(1)`
+ (время файла от бэка), иначе fallback на старое вычисление.
+
+### `site/app.py`
+- `VERSION = "0.0.41"`.
+
+## Проверка
+- События: `[start(0,0.0), start(1,0.0), done(0,<время>), done(1,<время>)]`.
+- `py_compile`, `node --check` — OK.
+- Тесты: builder 20/20, replacer 10/10, scanner 20/20, zip 28/28.
diff --git a/drhider/obfuscator.py b/drhider/obfuscator.py
index dd0d1e8..b8ce424 100644
--- a/drhider/obfuscator.py
+++ b/drhider/obfuscator.py
@@ -9,6 +9,7 @@
import io
import os
+import time
import logging
from typing import Dict, List, Tuple, Callable, Optional
@@ -94,9 +95,9 @@ class TwoPassObfuscator:
Args:
files: [(filename, content_bytes, content_type), ...]
- progress_cb: Опциональный коллбек (phase, idx, total, fname),
- где phase ∈ {"start", "done"}, idx — 0-based индекс.
- Вызывается вокруг финальной обработки каждого файла.
+ progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
+ где phase ∈ {"start", "done"}, idx — 0-based индекс,
+ elapsed — время обработки конкретного файла (сек).
Returns:
(zip_bytes, csv_string):
@@ -112,18 +113,21 @@ class TwoPassObfuscator:
# Извлекаем Markdown из каждого файла
all_texts: Dict[str, str] = {}
total = len(files)
+ file_times: List[float] = [0.0] * total
for i, (fname, content, ctype) in enumerate(files):
display_name = os.path.basename(fname) or fname
if progress_cb:
- progress_cb("start", i, total, display_name)
+ progress_cb("start", i, total, display_name, 0.0)
+ t0 = time.time()
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)
+ file_times[i] += time.time() - t0
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
if self._llm_client:
@@ -141,6 +145,7 @@ class TwoPassObfuscator:
fname = in_fname
display_name = os.path.basename(in_fname) or in_fname
+ t0 = time.time()
obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'):
@@ -163,9 +168,10 @@ class TwoPassObfuscator:
obf_content = replaced.encode('utf-8')
fname = md_name
+ file_times[i] += time.time() - t0
results.append((fname, obf_content))
if progress_cb:
- progress_cb("done", i, total, display_name)
+ progress_cb("done", i, total, display_name, round(file_times[i], 2))
# ── Сборка результата ──
csv_str = builder.build_mapping_csv(self._mapping)
diff --git a/site/app.py b/site/app.py
index e102cf3..d7034d2 100644
--- a/site/app.py
+++ b/site/app.py
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
-VERSION = "0.0.40"
+VERSION = "0.0.41"
def create_app():
diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py
index f22e73f..fa3198c 100644
--- a/site/routes/api_bp.py
+++ b/site/routes/api_bp.py
@@ -79,8 +79,8 @@ def process_stream(sid):
q = queue.Queue()
cancel = threading.Event()
- def progress(phase, idx, total_, name):
- q.put(("progress", phase, idx, name, total_))
+ def progress(phase, idx, total_, name, elapsed):
+ q.put(("progress", phase, idx, name, total_, elapsed))
def worker():
try:
@@ -117,11 +117,11 @@ def process_stream(sid):
kind = evt[0]
if kind == "progress":
- _, phase, idx, name, total_ = evt
+ _, phase, idx, name, total_, elapsed = evt
try:
yield (
f"event: {phase}\n"
- f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_})}\n\n"
+ f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
)
except _disconnect_exceptions():
cancel.set()
diff --git a/site/templates/index.html b/site/templates/index.html
index b6362dd..671e2f1 100644
--- a/site/templates/index.html
+++ b/site/templates/index.html
@@ -484,8 +484,11 @@ async function uploadFiles() {
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, '✓ ' + elapsed + 'с');
+ // Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
+ const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
+ ? d.elapsed.toFixed(1)
+ : ((performance.now() - (fileTimers[d.idx] || performance.now())) / 1000).toFixed(1);
+ ss(d.idx, '✓ ' + sec + 'с');
});
activeES.addEventListener('complete', function(e) {
activeES.close();