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