Files
catalog_doc/catalogizer/textutil.py
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

157 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 re
import unicodedata
CYR = re.compile(r"[а-яёА-ЯЁ]")
LAT = re.compile(r"[a-zA-Z]")
TOKEN_RE = re.compile(r"[a-zA-Zа-яёА-ЯЁ][a-zA-Zа-яёА-ЯЁ0-9\-]{1,30}")
RU_STOP = set("""
а без более больше будет будто бы был была были было быть в вам вас ведь весь
вдруг вот впрочем все всегда всего всех всю вы где да даже два для до другой
его ее её ей ему если есть еще ещё же за зачем здесь и из или им иметь их к
как какой когда конечно кто куда ли лишь между меня мне может можно мой моя мы
на над надо наш не него неё нее ней нельзя нет ни нибудь никогда ним них ничего
но ну о об один он она они оно от очень перед по под после потом потому почти
при про просто раз разве с сам свое своей свои свой себе себя сейчас сказать
со совсем так такой там те тебя тем теперь то тогда того тоже той только том
тот тут ты у уж уже хорошо хоть чего чей чем через что чтобы чуть эта эти это
этой этом этот эту я является является является том числе также т е т д т п
рис таблица глава раздел стр страница пример примера данной данного данные
можно должен должна должны следует таким образом однако если этом при этом
""".split())
EN_STOP = set("""
a about above after again against all am an and any are as at be because been
before being below between both but by can cannot could did do does doing down
during each few for from further had has have having he her here hers him his
how i if in into is it its itself just me more most must my no nor not of off
on once only or other our out over own same she should so some such than that
the their them then there these they this those through to too under until up
use used using very was we were what when where which while who whom why will
with would you your figure table chapter section page fig eq ref see note
""".split())
STOP = RU_STOP | EN_STOP
# Мусорные токены, характерные для технической документации.
JUNK = set("""
www http https com ru org net pdf doc docx page pages copyright reserved rights
inc ltd corp all datasheet rev revision version document documents note notes
""".split())
def detect_lang(text: str) -> str:
"""Грубое определение языка по соотношению кириллицы и латиницы."""
sample = text[:20_000]
cyr = len(CYR.findall(sample))
lat = len(LAT.findall(sample))
if cyr + lat < 30:
return "?"
return "ru" if cyr > lat * 0.5 else "en"
_RU_ENDINGS = (
"ированием", "ированию", "ирования", "ированный", "ирование",
"ами", "ями", "ого", "ему", "ому", "ыми", "ими", "ая", "ое", "ые", "ый",
"ой", "ий", "ин", "ов", "ев", "ам", "ям", "ах", "ях", "ею", "ью", "ия",
"ии", "ие", "ем", "ом", "ах", "ешь", "ете", "ает", "ают", "ить", "ать",
"ешь", "ся", "сь", "а", "е", "и", "й", "о", "у", "ы", "ь", "ю", "я",
)
def stem(word: str) -> str:
"""Лёгкий стеммер: RU — отсечение окончаний, EN — отсечение суффиксов.
Нужен не для лингвистической точности, а чтобы «инвертор», «инвертора»
и «инверторы» попадали в один индексный ключ.
"""
w = word.lower().replace("ё", "е")
if CYR.search(w):
for end in _RU_ENDINGS:
if len(w) - len(end) >= 4 and w.endswith(end):
return w[: -len(end)]
return w
for end in ("ingly", "ations", "ation", "ings", "edly", "ies", "ing",
"ers", "er", "es", "ed", "s"):
if len(w) - len(end) >= 4 and w.endswith(end):
base = w[: -len(end)]
if end == "ies":
base += "y"
return base
return w
def tokens(text: str) -> list[str]:
"""Значимые слова текста в нижнем регистре."""
out = []
for m in TOKEN_RE.finditer(text):
w = m.group(0).lower().replace("ё", "е")
if len(w) < 3 or w in STOP or w in JUNK:
continue
if w.strip("-") != w:
w = w.strip("-")
if len(w) < 3:
continue
out.append(w)
return out
def clean_text(raw: str) -> str:
"""Убирает переносы строк внутри слов, колонтитулы и лишние пробелы."""
t = unicodedata.normalize("NFKC", raw)
t = t.replace("­", "")
t = re.sub(r"-\n(?=[a-zа-яё])", "", t) # перенос по слогам
t = re.sub(r"(?<=[^\n\.\!\?:;])\n(?=[a-zа-яё])", " ", t) # разрыв строки
t = re.sub(r"[ \t ]+", " ", t)
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
SENT_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"'(\[]?[A-ZА-ЯЁ0-9])|\n{2,}")
def sentences(text: str) -> list[str]:
"""Разбивает текст на предложения, отбрасывая заведомый мусор."""
out = []
for chunk in SENT_SPLIT.split(text):
if not chunk:
continue
s = " ".join(chunk.split())
if not (40 <= len(s) <= 400):
continue
letters = sum(ch.isalpha() for ch in s)
if letters < len(s) * 0.55: # формулы, таблицы, номера страниц
continue
if s.count("|") > 2 or s.count("...") > 1 or s.count("__") > 0:
continue
words = s.split()
if len(words) < 6:
continue
upper = sum(w.isupper() for w in words)
if upper > len(words) * 0.6: # заголовок капсом
continue
out.append(s)
return out
def similar(a: str, b: str) -> float:
"""Мера пересечения двух предложений по множеству основ слов."""
sa = {stem(w) for w in tokens(a)}
sb = {stem(w) for w in tokens(b)}
if not sa or not sb:
return 0.0
return len(sa & sb) / len(sa | sb)
def pretty_name(filename: str) -> str:
"""Читаемое название из имени файла."""
name = re.sub(r"\.[A-Za-z0-9]{2,5}$", "", filename)
name = name.replace("_", " ").replace("%20", " ")
name = re.sub(r"[.\-]{2,}", " ", name)
name = re.sub(r"\s{2,}", " ", name).strip(" -.")
return name or filename