Files
Andrey Kruchinkin 49f91247c7 Каталогизатор документов: разбор папки, тезисы, поиск, 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>
2026-08-30 19:20:29 +03:00

384 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Командный интерфейс каталогизатора."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from . import analyze as analyze_mod
from . import config, db, report, scanner, search as search_mod
def _utf8_console() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
def _fmt_size(n: int) -> str:
return report._size(n)
def resolve_root(args) -> Path:
"""Папка с документами: позиционный аргумент, --root или прошлый запуск."""
given = (getattr(args, "catalog", None) or getattr(args, "folder", None)
or getattr(args, "root", None))
return Path(given).expanduser() if given else config.current_root()
def open_catalog(args, root: Path | None = None):
"""Подключение к базе того каталога, о котором идёт речь."""
if root is None:
root = resolve_root(args)
db_path, out_dir = config.paths_for(root)
if args.db:
db_path = Path(args.db)
if not getattr(args, "out", None):
args.out = str(out_dir)
return db.connect(db_path), root
# --------------------------------------------------------------------------
# Команды
# --------------------------------------------------------------------------
def cmd_scan(args) -> int:
root = resolve_root(args)
if not root.is_dir():
print("Папка не найдена: %s" % root)
return 2
con, root = open_catalog(args, root)
config.remember(root)
if args.retry_broken:
con.execute("DELETE FROM attempts")
con.commit()
print("Папка: %s" % root)
exts = None
if args.ext:
exts = {"." + e.lower().lstrip(".") for e in args.ext}
stats = scanner.scan(con, root, max_pages=args.max_pages,
jobs=args.jobs, force=args.force, limit=args.limit,
extensions=exts)
print("Разбор завершён: обработано %(processed)d, с ошибками %(errors)d, "
"пропущено %(skipped)d, за %(seconds)s с" % stats)
con.close()
return 0
def cmd_analyze(args) -> int:
con, _ = open_catalog(args)
stats = analyze_mod.analyze(con, force=args.force)
print("Анализ завершён: %(analyzed)d документов за %(seconds)s с" % stats)
con.close()
return 0
def cmd_report(args) -> int:
con, _ = open_catalog(args)
paths = report.build_all(con, args.out)
for kind, p in paths.items():
print("%-5s %s" % (kind, p))
con.close()
return 0
def cmd_build(args) -> int:
rc = cmd_scan(args) or cmd_analyze(args) or cmd_report(args)
return rc
def cmd_search(args) -> int:
con, _ = open_catalog(args)
rows = search_mod.search(con, " ".join(args.query), limit=args.limit,
ext=args.ext_filter, folder=args.section,
lang=args.lang, year_from=args.year_from,
kind=args.kind)
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=1))
con.close()
return 0
if not rows:
print("Ничего не найдено.")
con.close()
return 1
for i, r in enumerate(rows, 1):
facts = [r["ext"].lstrip("."), r["kind"] or ""]
if r["author"]:
facts.append(r["author"])
if r["year"]:
facts.append(str(r["year"]))
if r["pages"]:
facts.append("%d с." % r["pages"])
print("\n%2d. %s" % (i, r["title"]))
print(" %s" % " · ".join(f for f in facts if f))
if r["description"]:
print(" %s" % r["description"][:300])
if r["snip"]:
print("%s" % " ".join(r["snip"].split())[:280])
if r["user_keywords"]:
print(" свои слова: %s" % ", ".join(r["user_keywords"]))
if r["keywords"]:
print(" ключевые слова: %s" % ", ".join(r["keywords"][:8]))
print(" %s" % r["path"])
con.close()
return 0
def cmd_show(args) -> int:
con, _ = open_catalog(args)
if args.target.isdigit():
row = con.execute("SELECT * FROM docs WHERE id = ?",
(int(args.target),)).fetchone()
else:
row = con.execute(
"SELECT * FROM docs WHERE path LIKE ? OR rel_path LIKE ?"
" OR filename LIKE ? LIMIT 1",
("%" + args.target + "%",) * 3).fetchone()
if row is None:
print("Документ не найден.")
return 1
print(row["title"])
print("=" * min(len(row["title"]), 80))
for label, key in (("Автор", "author"), ("Год", "year"), ("Тип", "kind"),
("Язык", "lang"), ("Страниц", "pages"), ("Путь", "path")):
if row[key]:
print("%-9s %s" % (label + ":", row[key]))
print("%-9s %s" % ("Размер:", _fmt_size(row["size"])))
if row["error"]:
print("%-9s %s" % ("Ошибка:", row["error"]))
print("\nОписание:\n %s" % (row["description"] or ""))
theses = json.loads(row["theses"] or "[]")
if theses:
print("\nТезисы:")
for t in theses:
print("%s" % t)
kws = json.loads(row["keywords"] or "[]")
if kws:
print("\nКлючевые слова: %s" % ", ".join(kws))
toc = json.loads(row["toc"] or "[]")
if toc and args.toc:
print("\nОглавление:")
for t in toc[:60]:
print(" - %s" % t)
con.close()
return 0
def _find_doc(con, target: str):
"""Документ по числовому id или по части имени/пути."""
if target.isdigit():
return con.execute("SELECT * FROM docs WHERE id = ?",
(int(target),)).fetchone()
return con.execute(
"SELECT * FROM docs WHERE path LIKE ? OR rel_path LIKE ?"
" OR filename LIKE ? OR title LIKE ? LIMIT 1",
("%" + target + "%",) * 4).fetchone()
def cmd_tag(args) -> int:
"""Свои ключевые слова документа: показать, добавить, убрать, заменить."""
con, _ = open_catalog(args)
if args.all:
words = db.all_user_keywords(con)
print("\n".join(words) if words else "Своих ключевых слов пока нет.")
con.close()
return 0
row = _find_doc(con, args.target)
if row is None:
print("Документ не найден: %s" % args.target)
con.close()
return 1
current = db.get_user_keywords(con, row["path"])
words = [w for chunk in args.words for w in chunk.split(",") if w.strip()]
if args.remove:
drop = {w.strip().lower() for w in words}
new = [w for w in current if w.lower() not in drop]
elif args.set:
new = words
elif words:
new = current + words
else:
new = current # без слов — просто показать
if new != current:
new = db.set_user_keywords(con, row["path"], new)
print("%s\n свои ключевые слова: %s"
% (row["title"], ", ".join(new) if new else ""))
con.close()
return 0
def cmd_stats(args) -> int:
con, _ = open_catalog(args)
total = con.execute("SELECT COUNT(*) c, SUM(size) s FROM docs").fetchone()
if not total["c"]:
print("База пуста — выполните: python catalog.py build")
return 1
print("Корень: %s" % db.get_meta(con, "root"))
print("Обновлено: %s" % db.get_meta(con, "last_scan"))
print("Документов: %d (%s)" % (total["c"], _fmt_size(total["s"] or 0)))
print("С тезисами: %d" % con.execute(
"SELECT COUNT(*) c FROM docs WHERE analyzed = 1 AND scanned = 0").fetchone()["c"])
print("Сканов без текста: %d" % con.execute(
"SELECT COUNT(*) c FROM docs WHERE scanned = 1").fetchone()["c"])
print("С ошибками: %d" % con.execute(
"SELECT COUNT(*) c FROM docs WHERE error <> ''").fetchone()["c"])
print("\nПо форматам:")
for r in con.execute("SELECT ext, COUNT(*) c, SUM(size) s FROM docs"
" GROUP BY ext ORDER BY c DESC"):
print(" %-6s %5d %s" % (r["ext"], r["c"], _fmt_size(r["s"] or 0)))
print("\nКрупнейшие разделы:")
for r in con.execute("SELECT folder, COUNT(*) c FROM docs GROUP BY folder"
" ORDER BY c DESC LIMIT 15"):
print(" %5d %s" % (r["c"], r["folder"][:70]))
print("\nЧастые темы библиотеки:")
from collections import Counter
cnt: Counter = Counter()
for r in con.execute("SELECT keywords FROM docs WHERE keywords IS NOT NULL"):
cnt.update(json.loads(r["keywords"])[:8])
print(" " + ", ".join("%s (%d)" % (w, n) for w, n in cnt.most_common(25)))
con.close()
return 0
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="catalog",
description="Каталогизатор технической библиотеки: разбор файлов, "
"краткие описания, тезисы и поиск по ключевым словам.")
p.add_argument("-c", "--catalog", default=None, metavar="ПАПКА",
help="папка каталога для search/report/stats/show "
"(по умолчанию — обработанная последней)")
p.add_argument("--db", default=None,
help="файл базы каталога (по умолчанию свой для каждой папки)")
sub = p.add_subparsers(dest="cmd", required=False)
def add_scan_args(sp):
sp.add_argument("folder", nargs="?", default=None,
help="папка с документами (по умолчанию из config.py)")
sp.add_argument("--root", default=None,
help="то же самое, что и позиционная папка")
sp.add_argument("--max-pages", type=int, default=config.MAX_PDF_PAGES,
help="сколько страниц читать из документа")
sp.add_argument("--jobs", type=int, default=4,
help="потоков разбора (HDD — 2-4, SSD — 8-16)")
sp.add_argument("--limit", type=int, default=None,
help="разобрать не более N новых файлов (для пробы)")
sp.add_argument("--ext", nargs="*", default=None,
help="ограничить форматы, например: pdf djvu")
sp.add_argument("--force", action="store_true",
help="перечитать все файлы, а не только изменившиеся")
sp.add_argument("--retry-broken", action="store_true",
help="снова попробовать файлы, помеченные как битые")
sp = sub.add_parser("scan", help="обойти библиотеку и извлечь тексты")
add_scan_args(sp)
sp.set_defaults(func=cmd_scan)
sp = sub.add_parser("analyze", help="построить описания, тезисы, ключевые слова")
sp.add_argument("--force", action="store_true", help="пересчитать всё заново")
sp.set_defaults(func=cmd_analyze)
sp = sub.add_parser("report", help="выгрузить каталог (HTML, MD, JSON, CSV)")
sp.add_argument("folder", nargs="?", default=None,
help="папка каталога (по умолчанию — обработанная последней)")
sp.add_argument("--out", default=None,
help="папка выгрузки (по умолчанию out/ этого каталога)")
sp.set_defaults(func=cmd_report)
sp = sub.add_parser("build", help="полный цикл: scan + analyze + report")
add_scan_args(sp)
sp.add_argument("--out", default=None,
help="папка выгрузки (по умолчанию out/ этого каталога)")
sp.set_defaults(func=cmd_build)
sp = sub.add_parser("search", help="поиск по каталогу")
sp.add_argument("query", nargs="+", help="слова запроса; \"фраза\" — точная")
sp.add_argument("-n", "--limit", type=int, default=20)
sp.add_argument("--ext", dest="ext_filter", default=None, help="формат: pdf")
sp.add_argument("--section", "--folder", dest="section", default=None,
help="искать только в разделе (подстрока пути)")
sp.add_argument("--lang", default=None, choices=["ru", "en"])
sp.add_argument("--kind", default=None, help="тип: книга, даташит, учебник…")
sp.add_argument("--year-from", type=int, default=None)
sp.add_argument("--json", action="store_true", help="вывод в JSON")
sp.set_defaults(func=cmd_search)
sp = sub.add_parser("show", help="карточка документа по id или имени")
sp.add_argument("target")
sp.add_argument("--toc", action="store_true", help="показать оглавление")
sp.set_defaults(func=cmd_show)
sp = sub.add_parser("tag", help="свои ключевые слова документа")
sp.add_argument("target", nargs="?", default="",
help="id документа или часть имени/названия")
sp.add_argument("words", nargs="*", default=[],
help="слова через пробел или запятую")
sp.add_argument("--remove", action="store_true", help="убрать эти слова")
sp.add_argument("--set", action="store_true",
help="заменить список целиком (без слов — очистить)")
sp.add_argument("--all", action="store_true",
help="показать все свои ключевые слова каталога")
sp.set_defaults(func=cmd_tag)
sp = sub.add_parser("gui", help="графическое окно (по умолчанию)")
sp.add_argument("folder", nargs="?", default=None,
help="папка, открытая при запуске")
sp.set_defaults(func=cmd_gui)
sp = sub.add_parser("stats", help="сводка по каталогу")
sp.add_argument("folder", nargs="?", default=None,
help="папка каталога (по умолчанию — обработанная последней)")
sp.set_defaults(func=cmd_stats)
return p
def cmd_gui(args) -> int:
"""Графическое окно каталогизатора."""
try:
from .app import main as gui_main
except ImportError:
print("Не установлен PySide6: python -m pip install PySide6")
return 2
folder = getattr(args, "folder", None)
return gui_main([folder] if folder else [])
def interactive() -> list[str]:
"""Запуск без аргументов: открываем окно, а без PySide6 — спрашиваем папку."""
try:
import PySide6 # noqa: F401 — проверка наличия графической библиотеки
except ImportError:
pass
else:
return ["gui"]
print("Каталогизатор документов.")
print("Укажите папку с документами (Enter — %s):" % config.current_root())
try:
folder = input("> ").strip().strip('"')
except (EOFError, KeyboardInterrupt):
return ["--help"]
return ["build", folder] if folder else ["build"]
def main(argv: list[str] | None = None) -> int:
_utf8_console()
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
argv = interactive()
args = build_parser().parse_args(argv)
if not getattr(args, "func", None):
build_parser().print_help()
return 2
return args.func(args)