Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
176 lines
6.9 KiB
Python
176 lines
6.9 KiB
Python
"""Обход библиотеки и наполнение базы (этап «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)}
|