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

86
catalogizer/search.py Normal file
View File

@@ -0,0 +1,86 @@
"""Поиск по каталогу: полнотекстовый запрос с учётом морфологии."""
from __future__ import annotations
import json
import re
from .textutil import stem
SAFE = re.compile(r"[^\w\-Ѐ-ӿ]+", re.U)
# Веса колонок для bm25: заголовок и ключевые слова важнее тела документа.
BM25_WEIGHTS = (10.0, 8.0, 4.0, 1.0, 0.8, 2.0)
def build_query(text: str) -> str:
"""Превращает пользовательский запрос в выражение FTS5.
Каждое слово ищется и как есть, и по основе с префиксом — чтобы
«инвертор» находил «инверторы» и «инвертора».
"""
phrases = re.findall(r'"([^"]+)"', text)
rest = re.sub(r'"[^"]*"', " ", text)
parts = ['"%s"' % SAFE.sub(" ", p).strip() for p in phrases if p.strip()]
for raw in rest.split():
neg = raw.startswith("-")
word = SAFE.sub("", raw.lstrip("-+")).strip()
if len(word) < 2:
continue
st = stem(word)
variants = {'"%s"' % word.lower()}
if st and st != word.lower():
variants.add('"%s"*' % st)
else:
variants.add('"%s"*' % word.lower())
clause = "(" + " OR ".join(sorted(variants)) + ")"
parts.append(("NOT " + clause) if neg else clause)
return " AND ".join(p for p in parts if p).replace("AND NOT", "NOT")
def search(con, query: str, *, limit: int = 20, ext: str | None = None,
folder: str | None = None, lang: str | None = None,
year_from: int | None = None, kind: str | None = None) -> list[dict]:
fts_query = build_query(query)
if not fts_query:
return []
sql = [
"SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.lang, d.kind,",
" d.rel_path, d.path, d.folder, d.description, d.keywords, d.theses,",
" d.scanned, u.keywords AS user_keywords,",
" snippet(docs_fts, 3, '«', '»', ' … ', 14) AS snip,",
" bm25(docs_fts, %s) AS rank" % ", ".join(str(w) for w in BM25_WEIGHTS),
"FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid",
" LEFT JOIN user_keywords u ON u.path = d.path",
"WHERE docs_fts MATCH ?",
]
params: list = [fts_query]
if ext:
sql.append("AND d.ext = ?")
params.append("." + ext.lower().lstrip("."))
if folder:
sql.append("AND d.folder LIKE ?")
params.append("%" + folder + "%")
if lang:
sql.append("AND d.lang = ?")
params.append(lang)
if kind:
sql.append("AND d.kind = ?")
params.append(kind)
if year_from:
sql.append("AND d.year >= ?")
params.append(year_from)
sql.append("ORDER BY rank LIMIT ?")
params.append(limit)
rows = con.execute("\n".join(sql), params).fetchall()
out = []
for r in rows:
d = dict(r)
d["keywords"] = json.loads(d.get("keywords") or "[]")
d["theses"] = json.loads(d.get("theses") or "[]")
d["user_keywords"] = json.loads(d.get("user_keywords") or "[]")
d["score"] = round(-d.pop("rank"), 2)
out.append(d)
return out