Каталогизатор документов: разбор папки, тезисы, поиск, 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

155
scripts/build_exe.py Normal file
View File

@@ -0,0 +1,155 @@
"""@file build_exe.py
@brief Сборка автономной программы: Python, Qt и PyMuPDF внутри.
Запуск (из корня проекта, интерпретатором с PySide6 и PyInstaller):
python scripts/build_exe.py
Что получается, зависит от системы, на которой идёт сборка:
* Windows — ``dist/Catalogizer.exe``;
* macOS — ``dist/Catalogizer.app``;
* Linux — ``dist/Catalogizer``.
PyInstaller не умеет собирать под чужую систему: программу для macOS
собирают на macOS, для Windows — на Windows. Исходники при этом одни.
Готовая программа не требует ни Python, ни PySide6, ни PyMuPDF.
Ключи разобраны в ``--help``:
* ``--onedir`` — папка вместо одного файла: запускается заметно быстрее
(в macOS так всегда: ``.app`` по устройству и есть папка);
* ``--console`` — оставить консоль, чтобы видеть traceback и сообщения Qt;
* ``--cli`` — дополнительно собрать консольную программу ``catalog``.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
MACOS = sys.platform == "darwin"
WINDOWS = sys.platform == "win32"
#: Идентификатор пакета macOS: по нему система различает приложения.
BUNDLE_ID = "ru.set.catalogizer"
#: Корень проекта: скрипт лежит в scripts/.
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent)) # для make_icon
DIST = ROOT / "dist"
BUILD = ROOT / "build"
ICON = ROOT / "ico" / ("catalog.icns" if MACOS else "catalog.ico")
#: Модули Qt, которые приложению не нужны. Без этого списка в сборку тянутся
#: QtWebEngine и 3D-стек — сотни мегабайт мимо цели.
UNUSED_QT_MODULES = (
"QtWebEngineCore", "QtWebEngineWidgets", "QtWebEngineQuick",
"QtQuick", "QtQuick3D", "QtQml", "Qt3DCore", "Qt3DRender",
"QtMultimedia", "QtBluetooth", "QtCharts", "QtDataVisualization",
"QtGraphs", "QtPdf", "QtSql", "QtTest", "QtNetwork", "QtSerialPort",
)
#: Что исключается всегда: tkinter в программе не используется, а PySide2
#: рядом с PySide6 ломает сборку.
COMMON_EXCLUDES = ("tkinter", "PySide2", "shiboken2", "matplotlib",
"numpy", "PIL", "pytest")
def check_environment() -> None:
"""@brief Проверяет, что в интерпретаторе есть всё нужное для сборки."""
missing = []
for module, package in (("PyInstaller", "pyinstaller"),
("PySide6", "PySide6"),
("pymupdf", "pymupdf")):
try:
__import__(module)
except ImportError:
missing.append(package)
if missing:
raise SystemExit(
"Не хватает пакетов: %s\nУстановите их:\n %s -m pip install %s"
% (", ".join(missing), sys.executable, " ".join(missing)))
def build_command(name: str, entry: Path, args: argparse.Namespace,
windowed: bool) -> list[str]:
"""@brief Собирает командную строку PyInstaller для одной цели."""
command = [
sys.executable, "-m", "PyInstaller",
"--noconfirm",
"--clean",
"--name", name,
"--distpath", str(DIST),
"--workpath", str(BUILD),
"--specpath", str(BUILD),
# Пакет catalogizer лежит в корне проекта и не обязан быть установлен.
"--paths", str(ROOT),
]
# На macOS .app — это всегда папка, поэтому onefile там смысла не имеет.
command.append("--onedir" if (args.onedir or (MACOS and windowed))
else "--onefile")
# Консоль скрыта: приложение оконное. С --console видны traceback и
# сообщения Qt — так ищут причину, если программа молча не стартует.
command.append("--console" if (args.console or not windowed) else "--windowed")
if ICON.exists():
command += ["--icon", str(ICON)]
if MACOS and windowed:
command += ["--osx-bundle-identifier", BUNDLE_ID]
for module in UNUSED_QT_MODULES:
command += ["--exclude-module", "PySide6.%s" % module]
for module in COMMON_EXCLUDES:
command += ["--exclude-module", module]
command.append(str(entry))
return command
def run(command: list[str]) -> None:
print(" ".join('"%s"' % c if " " in c else c for c in command), flush=True)
result = subprocess.run(command, cwd=str(ROOT))
if result.returncode:
raise SystemExit("PyInstaller завершился с кодом %d" % result.returncode)
def main() -> int:
parser = argparse.ArgumentParser(
description="Сборка автономного Каталогизатора "
"(exe в Windows, .app в macOS)")
parser.add_argument("--onedir", action="store_true",
help="папка вместо одного файла: запуск быстрее")
parser.add_argument("--console", action="store_true",
help="оставить консоль у оконной сборки (для отладки)")
parser.add_argument("--cli", action="store_true",
help="дополнительно собрать консольную программу catalog")
args = parser.parse_args()
check_environment()
if not ICON.exists():
# Иконки не хранятся в репозитории — они целиком рисуются кодом.
import make_icon
print("Рисую иконки…", flush=True)
make_icon.build_ico()
make_icon.build_icns()
if BUILD.exists():
shutil.rmtree(BUILD, ignore_errors=True)
run(build_command("Catalogizer", ROOT / "catalog_gui.py", args, windowed=True))
if args.cli:
run(build_command("catalog", ROOT / "catalog.py", args, windowed=False))
if MACOS:
# Загрузчик .app обязан быть исполняемым, даже если права потерялись.
binary = DIST / "Catalogizer.app" / "Contents" / "MacOS" / "Catalogizer"
if binary.exists():
binary.chmod(0o755)
print("\nГотово. Файлы в %s" % DIST)
return 0
if __name__ == "__main__":
raise SystemExit(main())