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