Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
156 lines
6.8 KiB
Python
156 lines
6.8 KiB
Python
"""@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())
|