Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
157 lines
7.2 KiB
Python
157 lines
7.2 KiB
Python
"""Работа с текстом: язык, токены, стемминг, предложения."""
|
||
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
|