Каталогизатор документов: разбор папки, тезисы, поиск, GUI

Программа принимает папку с документами, извлекает из них текст и
метаданные и строит каталог: краткое описание, тезисы и ключевые слова
для каждого файла плюс поиск по всему собранному.

Ядро:
- extract: PDF, DJVU, DOC(X), RTF, ODT, CHM, EPUB, FB2, PPT(X), XLS(X), TXT;
  PDF передаётся MuPDF потоком в память (работают длинные пути) и ограничен
  по времени — повреждённый файл иначе чинится минутами;
- summarize: экстрактивное реферирование по TF-IDF, посчитанному на самой
  библиотеке, с лёгким стеммером для русского и английского;
- db: SQLite с полнотекстовым индексом FTS5, морфологический поиск с BM25;
- scanner: инкрементальный многопоточный обход, счётчик попыток защищает
  от файла, обрывающего разбор;
- report: автономный HTML с поиском, Markdown, JSON, CSV.

Интерфейс:
- окно PySide6 в тёмной теме: вкладки разбора и поиска, фоновый поток,
  карточка документа, правка своих ключевых слов;
- командная строка: build, scan, analyze, report, search, show, tag, stats;
- у каждой папки свой каталог, последняя обработанная запоминается.

Сборка: scripts/build_exe.py даёт Catalogizer.exe в Windows и
Catalogizer.app в macOS, иконки .ico и .icns рисуются кодом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 19:20:29 +03:00
commit 49f91247c7
30 changed files with 3934 additions and 0 deletions

78
catalogizer/ui/worker.py Normal file
View File

@@ -0,0 +1,78 @@
"""@file worker.py
@brief Фоновый поток построения каталога.
Разбор библиотеки идёт минутами и часами, поэтому выполняется в отдельном
потоке QThread: окно остаётся отзывчивым, а ход работы приходит сигналами.
"""
from __future__ import annotations
import traceback
from pathlib import Path
from PySide6.QtCore import QThread, Signal
from .. import analyze as analyze_mod
from .. import config, db, report, scanner
class BuildWorker(QThread):
"""@brief Полный цикл: обход папки, анализ, выгрузка отчётов."""
#: сделано, всего, поясняющая строка
progress = Signal(int, int, str)
#: строка в журнал
message = Signal(str)
#: итоговая строка состояния
finished_ok = Signal(str)
#: текст ошибки с трассировкой
failed = Signal(str)
def __init__(self, root: Path, jobs: int, max_pages: int,
force: bool = False, parent=None) -> None:
super().__init__(parent)
self._root = Path(root)
self._jobs = jobs
self._max_pages = max_pages
self._force = force
self._stop = False
def request_stop(self) -> None:
"""@brief Просит остановиться после текущей порции файлов.
Разобранное уже записано в базу, поэтому следующий запуск продолжит
с того же места.
"""
self._stop = True
def run(self) -> None: # noqa: D102 — контракт QThread
try:
config.remember(self._root)
db_path, out_dir = config.paths_for(self._root)
con = db.connect(db_path)
self.message.emit("Папка: %s" % self._root)
stats = scanner.scan(
con, self._root, max_pages=self._max_pages, jobs=self._jobs,
force=self._force, quiet=True,
progress=lambda d, t, m: self.progress.emit(d, t, m),
stop=lambda: self._stop)
self.message.emit(
"Разобрано %(processed)d, с ошибками %(errors)d, "
"пропущено %(skipped)d, за %(seconds)s с" % stats)
self.progress.emit(0, 0, "Анализ текстов…")
analyze_mod.analyze(
con, quiet=True,
progress=lambda d, t, m: self.progress.emit(d, t, m))
self.progress.emit(0, 0, "Выгрузка отчётов…")
for kind, path in report.build_all(con, out_dir).items():
self.message.emit("%-5s %s" % (kind, path))
total = con.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
con.close()
self.finished_ok.emit(
"Каталог готов: %d документов%s"
% (total, " (остановлено)" if self._stop else ""))
except Exception: # noqa: BLE001 — окно должно показать причину
self.failed.emit(traceback.format_exc())