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

176 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
"""Обход библиотеки и наполнение базы (этап «scan»)."""
from __future__ import annotations
import json
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from . import config, db
from .extract import extract, long_path
from .summarize import analyze_language, doc_terms, guess_kind
from .textutil import pretty_name
MAX_TRIES = 3 # после стольких обрывов файл помечается как непригодный
def iter_files(root: Path, extensions: set[str] | None = None):
"""Все файлы-документы библиотеки (с фильтрацией мусора и SDK-папок)."""
exts = extensions or config.DOC_EXTENSIONS
for dirpath, dirnames, filenames in os.walk(long_path(root)):
dirnames[:] = [d for d in dirnames
if d not in config.SKIP_DIR_NAMES and not d.startswith(".")]
for name in filenames:
ext = os.path.splitext(name)[1].lower()
if ext not in exts:
continue
if name.lower() in config.NOISE_FILENAMES:
continue
full = os.path.join(dirpath, name)
try:
st = os.stat(full)
except OSError:
continue
if st.st_size < config.MIN_FILE_SIZE:
continue
clean = full[4:] if full.startswith("\\\\?\\") else full
yield Path(clean), st
def _base_record(path: Path, st, root: Path) -> dict:
rel = os.path.relpath(str(path), str(root))
return {
"path": str(path),
"rel_path": rel,
"folder": os.path.dirname(rel) or ".",
"filename": path.name,
"ext": path.suffix.lower(),
"size": st.st_size,
"mtime": st.st_mtime,
"indexed_at": datetime.now().isoformat(timespec="seconds"),
}
def _build_record(path: Path, st, root: Path, max_pages: int) -> dict:
res = extract(path, max_pages)
text = res["text"]
terms = doc_terms(text, res["title"], res["toc"])
rec = _base_record(path, st, root)
rec.update({
"title": res["title"],
"author": res["author"],
"year": res["year"],
"pages": res["pages"],
"lang": analyze_language(text),
"kind": guess_kind(res["title"], text, res["pages"]),
"text_chars": len(text),
"scanned": int(len(text) < config.MIN_TEXT_CHARS),
"error": res["error"],
"body": text,
"toc": json.dumps(res["toc"][:120], ensure_ascii=False),
"terms": json.dumps(dict(terms.most_common(400)), ensure_ascii=False),
})
return rec
def _stub_record(path: Path, st, root: Path, reason: str) -> dict:
"""Запись о файле, который разобрать не удалось: он остаётся в каталоге."""
rec = _base_record(path, st, root)
rec.update({
"title": pretty_name(path.name), "author": "", "year": None,
"pages": None, "lang": "?", "kind": "документ", "text_chars": 0,
"scanned": 1, "error": reason, "body": "", "toc": "[]", "terms": "{}",
})
return rec
def scan(con, root: Path, *, max_pages: int = config.MAX_PDF_PAGES,
jobs: int = 4, force: bool = False, limit: int | None = None,
extensions: set[str] | None = None, quiet: bool = False,
progress=None, stop=None) -> dict:
"""Обходит библиотеку и обновляет записи изменившихся файлов.
Обход инкрементальный: повторный запуск читает только новые и
изменившиеся файлы, поэтому прерванный разбор можно продолжить.
"""
known = db.get_state(con)
broken = db.failed_paths(con, MAX_TRIES)
alive: set[str] = set()
todo: list[tuple[Path, os.stat_result]] = []
skipped = 0
for path, st in iter_files(root, extensions):
sp = str(path)
alive.add(sp)
prev = known.get(sp)
if not force and prev and abs(prev[0] - st.st_mtime) < 1 \
and prev[1] == st.st_size:
continue
if sp in broken:
# Файл уже несколько раз обрывал разбор — заносим без текста.
db.upsert_doc(con, _stub_record(
path, st, root,
"разбор аварийно прерывался %d раз(а)" % MAX_TRIES))
skipped += 1
continue
todo.append((path, st))
if limit and len(todo) >= limit:
break
removed = 0 if limit else db.purge_missing(con, alive)
con.commit()
total = len(todo)
msg = ("Найдено файлов: %d, к разбору: %d, пропущено битых: %d, "
"удалено из базы: %d" % (len(alive), total, skipped, removed))
if not quiet:
print(msg, flush=True)
if progress:
progress(0, total, msg)
done = errors = 0
started = time.time()
chunk = max(jobs, 10)
for offset in range(0, total, chunk):
batch = todo[offset: offset + chunk]
# Попытка отмечается до разбора: если процесс погибнет внутри
# библиотеки-парсера, при следующем запуске файл будет опознан.
for p, _ in batch:
db.bump_attempt(con, str(p))
con.commit()
with ThreadPoolExecutor(max_workers=jobs) as pool:
futures = {pool.submit(_build_record, p, s, root, max_pages): (p, s)
for p, s in batch}
for fut in as_completed(futures):
path, st = futures[fut]
try:
rec = fut.result()
except Exception as e: # noqa: BLE001
rec = _stub_record(path, st, root,
"%s: %s" % (type(e).__name__, e))
db.upsert_doc(con, rec)
db.clear_attempt(con, str(path))
done += 1
if rec["error"]:
errors += 1
con.commit()
speed = done / max(time.time() - started, 0.1)
eta = (total - done) / max(speed, 0.001)
line = ("%d/%d (%.1f файл/с, осталось ~%d мин)"
% (done, total, speed, eta / 60))
if not quiet:
print(" " + line, flush=True)
if progress:
progress(done, total, line)
if stop and stop():
break
db.set_meta(con, "root", str(root))
db.set_meta(con, "last_scan", datetime.now().isoformat(timespec="seconds"))
con.commit()
return {"total_files": len(alive), "processed": done, "errors": errors,
"removed": removed, "skipped": skipped,
"seconds": round(time.time() - started, 1)}