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