Каталогизатор документов: разбор папки, тезисы, поиск, 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>
This commit is contained in:
175
catalogizer/scanner.py
Normal file
175
catalogizer/scanner.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Обход библиотеки и наполнение базы (этап «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)}
|
||||
Reference in New Issue
Block a user