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

178
catalogizer/summarize.py Normal file
View File

@@ -0,0 +1,178 @@
"""Ключевые слова, тезисы и краткое описание документа.
Без обращения к внешним сервисам: экстрактивное реферирование по TF-IDF,
рассчитанному на самой библиотеке.
"""
from __future__ import annotations
import math
import re
from collections import Counter
from . import config
from .textutil import (STOP, detect_lang, sentences, similar, stem, tokens)
# Общие для любой техлитературы слова: сами по себе ничего не говорят.
GENERIC = {
"устройство", "система", "систем", "работа", "работы", "значение", "число",
"рисунок", "таблица", "формула", "пример", "случай", "результат", "метод",
"задача", "часть", "время", "уровень", "величина", "процесс", "качество",
"device", "system", "value", "number", "example", "case", "result",
"method", "task", "part", "time", "level", "process", "quality", "user",
"chapter", "figure", "section", "information", "application", "data",
}
GENERIC_STEMS = {stem(w) for w in GENERIC}
WORD_OK = re.compile(r"^[a-zа-я][a-zа-я0-9\-]{2,}$")
def doc_terms(text: str, title: str = "", toc: list[str] | None = None) -> Counter:
"""Частоты основ слов документа с усилением заголовка и оглавления."""
c: Counter = Counter()
for w in tokens(text):
if WORD_OK.match(w):
c[stem(w)] += 1
for w in tokens(title):
if WORD_OK.match(w):
c[stem(w)] += 6
for line in (toc or [])[:120]:
for w in tokens(line):
if WORD_OK.match(w):
c[stem(w)] += 3
return c
def _surface_forms(text: str, title: str, toc: list[str] | None) -> dict[str, str]:
"""Для каждой основы — самая частая словоформа (её и показываем)."""
forms: dict[str, Counter] = {}
for src in (title, "\n".join(toc or []), text[:15_000]):
for w in tokens(src):
if WORD_OK.match(w):
forms.setdefault(stem(w), Counter())[w] += 1
best = {}
for st, c in forms.items():
top = c.most_common(1)[0][1]
# Среди частых словоформ берём самую короткую: «вход», а не «входами».
good = [w for w, n in c.items() if n >= top * 0.5]
best[st] = min(good, key=lambda w: (len(w), w))
return best
class Analyzer:
"""Считает ключевые слова и тезисы с учётом IDF по всей библиотеке."""
def __init__(self, df: dict[str, int], n_docs: int):
self.df = df
self.n_docs = max(n_docs, 1)
def idf(self, term: str) -> float:
return math.log((self.n_docs + 1) / (self.df.get(term, 0) + 1)) + 1.0
def weights(self, terms: Counter) -> dict[str, float]:
"""Вес основы = сглаженный TF × IDF, без слишком общих слов."""
total = sum(terms.values()) or 1
w = {}
for t, n in terms.items():
if t in GENERIC_STEMS or t in STOP or len(t) < 3:
continue
if n < 2 and total > 400:
continue
w[t] = (1 + math.log(n)) * self.idf(t) / math.log(total + 10)
return w
def keywords(self, terms: Counter, text: str, title: str,
toc: list[str] | None, limit: int = config.N_KEYWORDS) -> list[str]:
w = self.weights(terms)
forms = _surface_forms(text, title, toc)
best = sorted(w.items(), key=lambda kv: -kv[1])
out: list[str] = []
for st, _ in best:
word = forms.get(st, st)
if any(word in o or o in word for o in out):
continue
out.append(word)
if len(out) >= limit:
break
return out
def theses(self, text: str, terms: Counter,
limit: int = config.N_THESES) -> list[str]:
"""Экстрактивные тезисы: самые «плотные» по ключевым словам фразы."""
w = self.weights(terms)
cands = sentences(text)
if not cands:
return []
scored = []
for pos, s in enumerate(cands):
ws = [w.get(stem(t), 0.0) for t in tokens(s)]
if len(ws) < 5:
continue
score = sum(ws) / math.sqrt(len(ws))
# Начало документа (аннотация, введение) — обычно самое ценное.
score *= 1.0 + 0.4 * max(0.0, 1.0 - pos / 40.0)
if re.search(r"(предназначен|рассматрива|описыва|посвящ|позволяет"
r"|книга|пособие|руководство|describes|presents"
r"|provides|introduces|this (book|guide|manual))",
s, re.I):
score *= 1.35
scored.append((score, pos, s))
scored.sort(key=lambda x: -x[0])
picked: list[tuple[int, str]] = []
for _, pos, s in scored:
if any(similar(s, p) > 0.55 for _, p in picked):
continue
picked.append((pos, s))
if len(picked) >= limit:
break
picked.sort(key=lambda x: x[0])
return [s for _, s in picked]
TYPE_HINTS = (
(r"datasheet|data sheet|техническое описание", "даташит"),
(r"reference manual|user manual|руководство пользователя", "руководство"),
(r"application note|прикладн", "апноут"),
(r"учебн|пособие|курс лекций|лекци", "учебник"),
(r"диссертац|автореферат", "диссертация"),
(r"гост|стандарт|iec |ieee std", "стандарт"),
(r"статья|journal|proceedings", "статья"),
(r"errata|release notes", "заметки к выпуску"),
)
def guess_kind(title: str, text: str, pages: int | None) -> str:
"""Тип издания — по названию, первым страницам и объёму."""
probe = (title + " " + text[:3000]).lower()
for pat, kind in TYPE_HINTS:
if re.search(pat, probe):
return kind
if pages and pages > 120:
return "книга"
if pages and pages <= 20:
return "заметка"
return "документ"
def describe(title: str, kind: str, lang: str, pages: int | None,
keywords: list[str], theses: list[str], has_text: bool) -> str:
"""Краткое описание в одну-две фразы."""
head = kind.capitalize()
if lang in ("ru", "en"):
head += " на %s языке" % ("русском" if lang == "ru" else "английском")
if pages:
head += ", %d с" % pages
if keywords:
head += ". Темы: " + ", ".join(keywords[:6])
if not has_text:
return head + ". Текстовый слой отсутствует (скан) — описание по имени файла."
if theses:
first = theses[0]
if len(first) > 220:
first = first[:217].rsplit(" ", 1)[0] + "…"
head += ". " + first
return head + ("" if head.endswith((".", "…")) else ".")
def analyze_language(text: str) -> str:
return detect_lang(text)