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

175
catalogizer/scanner.py Normal file
View File

@@ -0,0 +1,175 @@
"""Обход библиотеки и наполнение базы (этап «scan»)."""
from __future__ import annotations
import json
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from . import config, db
from .extract import extract, long_path
from .summarize import analyze_language, doc_terms, guess_kind
from .textutil import pretty_name
MAX_TRIES = 3 # после стольких обрывов файл помечается как непригодный
def iter_files(root: Path, extensions: set[str] | None = None):
"""Все файлы-документы библиотеки (с фильтрацией мусора и SDK-папок)."""
exts = extensions or config.DOC_EXTENSIONS
for dirpath, dirnames, filenames in os.walk(long_path(root)):
dirnames[:] = [d for d in dirnames
if d not in config.SKIP_DIR_NAMES and not d.startswith(".")]
for name in filenames:
ext = os.path.splitext(name)[1].lower()
if ext not in exts:
continue
if name.lower() in config.NOISE_FILENAMES:
continue
full = os.path.join(dirpath, name)
try:
st = os.stat(full)
except OSError:
continue
if st.st_size < config.MIN_FILE_SIZE:
continue
clean = full[4:] if full.startswith("\\\\?\\") else full
yield Path(clean), st
def _base_record(path: Path, st, root: Path) -> dict:
rel = os.path.relpath(str(path), str(root))
return {
"path": str(path),
"rel_path": rel,
"folder": os.path.dirname(rel) or ".",
"filename": path.name,
"ext": path.suffix.lower(),
"size": st.st_size,
"mtime": st.st_mtime,
"indexed_at": datetime.now().isoformat(timespec="seconds"),
}
def _build_record(path: Path, st, root: Path, max_pages: int) -> dict:
res = extract(path, max_pages)
text = res["text"]
terms = doc_terms(text, res["title"], res["toc"])
rec = _base_record(path, st, root)
rec.update({
"title": res["title"],
"author": res["author"],
"year": res["year"],
"pages": res["pages"],
"lang": analyze_language(text),
"kind": guess_kind(res["title"], text, res["pages"]),
"text_chars": len(text),
"scanned": int(len(text) < config.MIN_TEXT_CHARS),
"error": res["error"],
"body": text,
"toc": json.dumps(res["toc"][:120], ensure_ascii=False),
"terms": json.dumps(dict(terms.most_common(400)), ensure_ascii=False),
})
return rec
def _stub_record(path: Path, st, root: Path, reason: str) -> dict:
"""Запись о файле, который разобрать не удалось: он остаётся в каталоге."""
rec = _base_record(path, st, root)
rec.update({
"title": pretty_name(path.name), "author": "", "year": None,
"pages": None, "lang": "?", "kind": "документ", "text_chars": 0,
"scanned": 1, "error": reason, "body": "", "toc": "[]", "terms": "{}",
})
return rec
def scan(con, root: Path, *, max_pages: int = config.MAX_PDF_PAGES,
jobs: int = 4, force: bool = False, limit: int | None = None,
extensions: set[str] | None = None, quiet: bool = False,
progress=None, stop=None) -> dict:
"""Обходит библиотеку и обновляет записи изменившихся файлов.
Обход инкрементальный: повторный запуск читает только новые и
изменившиеся файлы, поэтому прерванный разбор можно продолжить.
"""
known = db.get_state(con)
broken = db.failed_paths(con, MAX_TRIES)
alive: set[str] = set()
todo: list[tuple[Path, os.stat_result]] = []
skipped = 0
for path, st in iter_files(root, extensions):
sp = str(path)
alive.add(sp)
prev = known.get(sp)
if not force and prev and abs(prev[0] - st.st_mtime) < 1 \
and prev[1] == st.st_size:
continue
if sp in broken:
# Файл уже несколько раз обрывал разбор — заносим без текста.
db.upsert_doc(con, _stub_record(
path, st, root,
"разбор аварийно прерывался %d раз(а)" % MAX_TRIES))
skipped += 1
continue
todo.append((path, st))
if limit and len(todo) >= limit:
break
removed = 0 if limit else db.purge_missing(con, alive)
con.commit()
total = len(todo)
msg = ("Найдено файлов: %d, к разбору: %d, пропущено битых: %d, "
"удалено из базы: %d" % (len(alive), total, skipped, removed))
if not quiet:
print(msg, flush=True)
if progress:
progress(0, total, msg)
done = errors = 0
started = time.time()
chunk = max(jobs, 10)
for offset in range(0, total, chunk):
batch = todo[offset: offset + chunk]
# Попытка отмечается до разбора: если процесс погибнет внутри
# библиотеки-парсера, при следующем запуске файл будет опознан.
for p, _ in batch:
db.bump_attempt(con, str(p))
con.commit()
with ThreadPoolExecutor(max_workers=jobs) as pool:
futures = {pool.submit(_build_record, p, s, root, max_pages): (p, s)
for p, s in batch}
for fut in as_completed(futures):
path, st = futures[fut]
try:
rec = fut.result()
except Exception as e: # noqa: BLE001
rec = _stub_record(path, st, root,
"%s: %s" % (type(e).__name__, e))
db.upsert_doc(con, rec)
db.clear_attempt(con, str(path))
done += 1
if rec["error"]:
errors += 1
con.commit()
speed = done / max(time.time() - started, 0.1)
eta = (total - done) / max(speed, 0.001)
line = ("%d/%d (%.1f файл/с, осталось ~%d мин)"
% (done, total, speed, eta / 60))
if not quiet:
print(" " + line, flush=True)
if progress:
progress(done, total, line)
if stop and stop():
break
db.set_meta(con, "root", str(root))
db.set_meta(con, "last_scan", datetime.now().isoformat(timespec="seconds"))
con.commit()
return {"total_files": len(alive), "processed": done, "errors": errors,
"removed": removed, "skipped": skipped,
"seconds": round(time.time() - started, 1)}