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

119
catalogizer/config.py Normal file
View File

@@ -0,0 +1,119 @@
"""Настройки каталогизатора."""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _default_root() -> Path:
"""Папка, предлагаемая при первом запуске.
Переопределяется переменной окружения CATALOG_ROOT или аргументом
команды. Жёсткий путь к библиотеке действует только там, где он есть,
иначе берётся домашняя папка «Документы» — так программа осмысленно
стартует и на macOS, и на Linux.
"""
given = os.environ.get("CATALOG_ROOT")
if given:
return Path(given).expanduser()
library = Path(r"E:\Yandex.Disk\литература")
if library.is_dir():
return library
for name in ("Documents", "Документы"):
candidate = Path.home() / name
if candidate.is_dir():
return candidate
return Path.home()
DEFAULT_ROOT = _default_root()
def _app_dir() -> Path:
"""Папка, где программа держит базы каталогов и отчёты.
Из исходников — корень проекта. В собранной программе — рядом с самим
файлом, кроме macOS: там ``.app`` считается неизменяемым (и может
запускаться из карантина), поэтому данные уходят в
``~/Library/Application Support``.
"""
if not getattr(sys, "frozen", False):
return Path(__file__).resolve().parent.parent
if sys.platform == "darwin":
target = Path.home() / "Library" / "Application Support" / "Catalogizer"
target.mkdir(parents=True, exist_ok=True)
return target
return Path(sys.executable).resolve().parent
APP_DIR = _app_dir()
DB_PATH = APP_DIR / "catalog.db" # база «главной» библиотеки
OUT_DIR = APP_DIR / "out" # отчёты по ней
CATALOGS_DIR = APP_DIR / "catalogs" # базы остальных обработанных папок
CURRENT_FILE = APP_DIR / ".current" # последняя обработанная папка
# Расширения, которые считаем «документами» библиотеки.
DOC_EXTENSIONS = {
".pdf", ".djvu", ".djv",
".doc", ".docx", ".rtf", ".odt",
".chm", ".epub", ".fb2",
".ppt", ".pptx", ".xls", ".xlsx",
".txt", ".md",
}
# Каталоги, которые не имеет смысла разбирать (SDK, репозитории, сборки).
SKIP_DIR_NAMES = {
".git", ".svn", "node_modules", "__pycache__", ".idea", ".vscode",
"Debug", "Release", "obj", "build", "cmake-build-debug",
}
# Мусорные txt/md из состава SDK и репозиториев — не тащим в каталог.
NOISE_FILENAMES = {
"readme.txt", "readme.md", "license.txt", "license.md", "licence.txt",
"changelog.md", "changelog.txt", "contributing.md", "code_of_conduct.md",
"manifest.txt", "makefile.txt", "notice.txt", "authors.md",
}
# Ограничения разбора.
MAX_PDF_PAGES = 20 # сколько страниц читаем из начала документа
MAX_TEXT_CHARS = 30_000 # сколько символов текста храним в базе
MIN_TEXT_CHARS = 200 # меньше — считаем, что текстового слоя нет (скан)
MIN_FILE_SIZE = 2_048 # файлы мельче игнорируем
MAX_PDF_BYTES = 400_000_000 # PDF крупнее не разбираем
PDF_TIME_BUDGET = 25 # секунд на один PDF: повреждённые чинятся вечно
# Сколько тезисов и ключевых слов делать на документ.
N_THESES = 5
N_KEYWORDS = 12
def _slug(root: Path) -> str:
"""Короткое имя каталога, однозначно привязанное к пути папки."""
import hashlib
import re
name = re.sub(r"[^\w\-]+", "_", root.name, flags=re.U).strip("_") or "catalog"
tail = hashlib.sha1(str(root).lower().encode("utf-8")).hexdigest()[:6]
return "%s-%s" % (name[:40], tail)
def paths_for(root: Path) -> tuple[Path, Path]:
"""База и папка отчётов для указанной папки с документами."""
root = Path(root).resolve()
if root == Path(DEFAULT_ROOT).resolve():
return DB_PATH, OUT_DIR
slug = _slug(root)
return CATALOGS_DIR / (slug + ".db"), OUT_DIR / slug
def remember(root: Path) -> None:
"""Запоминает последнюю обработанную папку — её и берут search/report."""
CURRENT_FILE.write_text(str(Path(root).resolve()), encoding="utf-8")
def current_root() -> Path:
"""Папка, с которой работали в последний раз."""
try:
value = CURRENT_FILE.read_text(encoding="utf-8").strip()
except OSError:
return Path(DEFAULT_ROOT)
return Path(value) if value else Path(DEFAULT_ROOT)