Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
76 lines
3.0 KiB
Python
76 lines
3.0 KiB
Python
"""Второй проход: тезисы, ключевые слова, описания (этап «analyze»).
|
|
|
|
IDF считается по всей библиотеке, поэтому проход выполняется после того,
|
|
как тексты всех документов уже лежат в базе.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from collections import Counter
|
|
|
|
from . import db
|
|
from .summarize import Analyzer, describe
|
|
from .textutil import stem, tokens
|
|
|
|
|
|
def build_df(con) -> tuple[dict[str, int], int]:
|
|
"""Документная частота каждой основы слова по всей библиотеке."""
|
|
df: Counter = Counter()
|
|
n = 0
|
|
for row in con.execute("SELECT terms FROM docs WHERE terms IS NOT NULL"):
|
|
try:
|
|
terms = json.loads(row["terms"])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
n += 1
|
|
df.update(terms.keys())
|
|
return dict(df), n
|
|
|
|
|
|
def analyze(con, *, force: bool = False, quiet: bool = False,
|
|
progress=None) -> dict:
|
|
df, n_docs = build_df(con)
|
|
an = Analyzer(df, n_docs)
|
|
|
|
where = "" if force else " WHERE analyzed = 0"
|
|
ids = [r["id"] for r in con.execute("SELECT id FROM docs" + where)]
|
|
total = len(ids)
|
|
if not quiet:
|
|
print("Документов в базе: %d, к анализу: %d" % (n_docs, total), flush=True)
|
|
if progress:
|
|
progress(0, total, "Анализ: %d документов" % total)
|
|
|
|
started = time.time()
|
|
for i, doc_id in enumerate(ids, 1):
|
|
row = con.execute(
|
|
"SELECT title, body, toc, terms, lang, kind, pages, scanned, filename"
|
|
" FROM docs WHERE id = ?", (doc_id,)).fetchone()
|
|
body = row["body"] or ""
|
|
try:
|
|
terms = Counter(json.loads(row["terms"] or "{}"))
|
|
toc = json.loads(row["toc"] or "[]")
|
|
except ValueError:
|
|
terms, toc = Counter(), []
|
|
|
|
keywords = an.keywords(terms, body, row["title"] or "", toc)
|
|
theses = an.theses(body, terms) if not row["scanned"] else []
|
|
if not theses and toc:
|
|
# Для сканов и справок без связного текста тезисы берём из оглавления.
|
|
theses = [t for t in toc[:8] if len(t) > 8]
|
|
description = describe(row["title"] or row["filename"], row["kind"] or "",
|
|
row["lang"] or "?", row["pages"], keywords,
|
|
theses, not row["scanned"])
|
|
stems = " ".join(stem(t) for t in tokens(body[:20_000]))
|
|
db.save_summary(con, doc_id, description, theses, keywords, stems)
|
|
|
|
if i % 100 == 0:
|
|
con.commit()
|
|
if not quiet:
|
|
print(" %d/%d" % (i, total), flush=True)
|
|
if progress:
|
|
progress(i, total, "Анализ %d/%d" % (i, total))
|
|
con.commit()
|
|
return {"analyzed": total, "corpus": n_docs,
|
|
"seconds": round(time.time() - started, 1)}
|