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

350 lines
13 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.
"""Извлечение текста и метаданных из файлов библиотеки."""
from __future__ import annotations
import os
import re
import shutil
import struct
import subprocess
import time
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
from . import config
from .textutil import clean_text, pretty_name
try: # PyMuPDF ставится как pymupdf, исторический импорт — fitz
import pymupdf as fitz
except ImportError: # pragma: no cover
try:
import fitz
except ImportError:
fitz = None
if fitz is not None:
# Повреждённые PDF иначе засыпают консоль сообщениями MuPDF о починке.
try:
fitz.TOOLS.mupdf_display_errors(False)
except AttributeError: # pragma: no cover — другая версия PyMuPDF
pass
DJVUTXT = shutil.which("djvutxt")
ANTIWORD = shutil.which("antiword")
def long_path(p: str | Path) -> str:
"""Путь в форме, которую Windows принимает при длине > 260 символов."""
s = os.fspath(p)
if os.name == "nt" and not s.startswith("\\\\?\\"):
s = "\\\\?\\" + os.path.abspath(s)
return s
def _read_bytes(path: Path, limit: int | None = None) -> bytes:
with open(long_path(path), "rb") as f:
return f.read(limit) if limit else f.read()
def _decode(data: bytes) -> str:
"""Декодирование текста без chardet: пробуем типовые кодировки."""
for enc in ("utf-8-sig", "utf-8"):
try:
return data.decode(enc)
except UnicodeDecodeError:
pass
if data[:2] in (b"\xff\xfe", b"\xfe\xff"):
try:
return data.decode("utf-16")
except UnicodeDecodeError:
pass
best, best_score = "", -1.0
for enc in ("cp1251", "koi8-r", "cp866", "cp1252", "latin-1"):
try:
t = data.decode(enc)
except UnicodeDecodeError:
continue
good = sum(ch.isalpha() or ch.isspace() or ch.isdigit() for ch in t)
score = good / max(len(t), 1)
if score > best_score:
best, best_score = t, score
return best
# --------------------------------------------------------------------------
# Форматы
# --------------------------------------------------------------------------
def _from_pdf(path: Path, max_pages: int) -> dict:
"""Текст и метаданные PDF.
Файл читается средствами Python и передаётся MuPDF потоком: так работают
длинные и нестандартные пути, а сам разбор ограничен по времени —
повреждённый PDF MuPDF пытается чинить страницу за страницей и может
занять минуты.
"""
if fitz is None:
return {"error": "PyMuPDF не установлен (pip install pymupdf)"}
if path.stat().st_size > config.MAX_PDF_BYTES:
return {"error": "PDF больше %d МБ — пропущен"
% (config.MAX_PDF_BYTES // 1_000_000)}
started = time.time()
out: dict = {}
data = _read_bytes(path)
with fitz.open(stream=data, filetype="pdf") as doc:
if doc.needs_pass:
return {"pages": doc.page_count, "error": "PDF защищён паролем"}
out["pages"] = doc.page_count
meta = doc.metadata or {}
out["title"] = (meta.get("title") or "").strip()
out["author"] = (meta.get("author") or "").strip()
# Оглавление — концентрированный источник о содержании книги.
try:
toc = doc.get_toc()
except Exception:
toc = []
if toc:
out["toc"] = [t[1].strip() for t in toc[:120] if t[1].strip()]
parts = []
for i in range(min(max_pages, doc.page_count)):
try:
parts.append(doc[i].get_text("text"))
except Exception:
continue
if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5:
break
out["text"] = "\n".join(parts)
return out
def _djvu_pages(path: Path) -> int | None:
"""Число страниц из заголовка DJVU (чанк DIRM) без внешних утилит."""
try:
head = _read_bytes(path, 4096)
except OSError:
return None
idx = head.find(b"DIRM")
if idx > 0 and len(head) > idx + 11:
try:
n = struct.unpack(">H", head[idx + 9: idx + 11])[0]
except struct.error:
return None
# Каталог DIRM содержит и служебные компоненты — берём только
# правдоподобные значения.
return n if 1 <= n <= 5000 else None
return 1 if b"INFO" in head else None
def _from_djvu(path: Path, max_pages: int) -> dict:
out: dict = {"pages": _djvu_pages(path)}
if DJVUTXT:
try:
r = subprocess.run(
[DJVUTXT, "--page=1-%d" % max_pages, str(path)],
capture_output=True, timeout=120,
)
out["text"] = _decode(r.stdout)
except (subprocess.SubprocessError, OSError) as e:
out["error"] = "djvutxt: %s" % e
else:
out["error"] = "нет djvutxt (DjVuLibre) — текст DJVU не извлечён"
return out
def _zip_xml_text(path: Path, members: list[str], tags: tuple[str, ...]) -> str:
parts: list[str] = []
with zipfile.ZipFile(long_path(path)) as z:
names = z.namelist()
wanted = [n for n in names if any(re.fullmatch(m, n) for m in members)]
for n in sorted(wanted):
try:
root = ET.fromstring(z.read(n))
except (ET.ParseError, KeyError):
continue
for el in root.iter():
tag = el.tag.rsplit("}", 1)[-1]
if tag in tags and el.text:
parts.append(el.text)
if tag in ("p", "br", "tab"):
parts.append("\n")
if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5:
break
return "".join(parts)
def _ooxml_meta(path: Path) -> dict:
try:
with zipfile.ZipFile(long_path(path)) as z:
root = ET.fromstring(z.read("docProps/core.xml"))
except (KeyError, zipfile.BadZipFile, ET.ParseError, OSError):
return {}
vals = {}
for el in root:
tag = el.tag.rsplit("}", 1)[-1]
if el.text:
vals[tag] = el.text.strip()
return {"title": vals.get("title", ""), "author": vals.get("creator", "")}
def _from_docx(path: Path) -> dict:
out = _ooxml_meta(path)
out["text"] = _zip_xml_text(path, [r"word/document\.xml"], ("t",))
return out
def _from_pptx(path: Path) -> dict:
out = _ooxml_meta(path)
out["text"] = _zip_xml_text(path, [r"ppt/slides/slide\d+\.xml"], ("t",))
return out
def _from_xlsx(path: Path) -> dict:
out = _ooxml_meta(path)
out["text"] = _zip_xml_text(path, [r"xl/sharedStrings\.xml"], ("t",))
return out
def _from_binary_office(path: Path) -> dict:
"""Word 97 / Excel 97 / PowerPoint 97: antiword, иначе выборка строк."""
if ANTIWORD and path.suffix.lower() == ".doc":
try:
r = subprocess.run([ANTIWORD, "-m", "UTF-8.txt", str(path)],
capture_output=True, timeout=90)
if r.stdout:
return {"text": _decode(r.stdout)}
except (subprocess.SubprocessError, OSError):
pass
data = _read_bytes(path, 2_000_000)
text = _decode(data)
chunks = re.findall(
r"[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9 ,.;:()\-«»\"']{25,}", text)
return {"text": "\n".join(chunks[:2000])}
def _from_rtf(path: Path) -> dict:
raw = _decode(_read_bytes(path, 4_000_000))
raw = re.sub(r"\\'([0-9a-fA-F]{2})",
lambda m: bytes([int(m.group(1), 16)]).decode("cp1251", "ignore"),
raw)
raw = re.sub(r"\\u(-?\d+)\s?\??", lambda m: chr(int(m.group(1)) % 65536), raw)
raw = re.sub(r"\{\\\*.*?\}", " ", raw, flags=re.S)
raw = re.sub(r"\\[a-zA-Z]+-?\d*\s?", " ", raw)
return {"text": raw.replace("{", " ").replace("}", " ")}
def _from_fb2(path: Path) -> dict:
raw = _decode(_read_bytes(path, 8_000_000))
title = re.search(r"<book-title>(.*?)</book-title>", raw, re.S)
author = re.findall(r"<(?:first|last)-name>(.*?)</(?:first|last)-name>", raw)
return {"title": title.group(1).strip() if title else "",
"author": " ".join(author[:2]),
"text": re.sub(r"<[^>]+>", " ", raw)}
def _from_epub(path: Path) -> dict:
parts: list[str] = []
title = author = ""
with zipfile.ZipFile(long_path(path)) as z:
for n in z.namelist():
if n.endswith(".opf"):
meta = _decode(z.read(n))
m = re.search(r"<dc:title[^>]*>(.*?)</dc:title>", meta, re.S)
a = re.search(r"<dc:creator[^>]*>(.*?)</dc:creator>", meta, re.S)
title = m.group(1).strip() if m else ""
author = a.group(1).strip() if a else ""
break
for n in sorted(z.namelist()):
if n.lower().endswith((".xhtml", ".html", ".htm")):
parts.append(re.sub(r"<[^>]+>", " ", _decode(z.read(n))))
if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5:
break
return {"title": title, "author": author, "text": "\n".join(parts)}
def _from_chm(path: Path) -> dict:
"""Из CHM без chmlib берём читаемые заголовки разделов справки."""
text = _decode(_read_bytes(path, 1_500_000))
titles = re.findall(r'<param name="Name" value="([^"]{4,120})"', text)
if not titles:
titles = re.findall(r"<title>([^<]{4,120})</title>", text, re.I)
return {"text": "\n".join(dict.fromkeys(titles))[: config.MAX_TEXT_CHARS],
"error": "" if titles else "CHM: текст не извлекается без chmlib"}
def _from_plain(path: Path) -> dict:
raw = _decode(_read_bytes(path, 4_000_000))
if path.suffix.lower() in (".html", ".htm"):
raw = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", raw, flags=re.S | re.I)
raw = re.sub(r"<[^>]+>", " ", raw)
return {"text": raw}
_HANDLERS = {
".pdf": lambda p, mp: _from_pdf(p, mp),
".djvu": lambda p, mp: _from_djvu(p, mp),
".djv": lambda p, mp: _from_djvu(p, mp),
".docx": lambda p, mp: _from_docx(p),
".doc": lambda p, mp: _from_binary_office(p),
".rtf": lambda p, mp: _from_rtf(p),
".odt": lambda p, mp: {"text": _zip_xml_text(p, [r"content\.xml"],
("p", "h", "span"))},
".pptx": lambda p, mp: _from_pptx(p),
".ppt": lambda p, mp: _from_binary_office(p),
".xlsx": lambda p, mp: _from_xlsx(p),
".xls": lambda p, mp: _from_binary_office(p),
".epub": lambda p, mp: _from_epub(p),
".fb2": lambda p, mp: _from_fb2(p),
".chm": lambda p, mp: _from_chm(p),
".txt": lambda p, mp: _from_plain(p),
".md": lambda p, mp: _from_plain(p),
}
YEAR_RE = re.compile(r"\b(19[5-9]\d|20[0-4]\d)\b")
BAD_TITLE_MARKS = ("untitled", "microsoft word", "pdfcreator", "документ",
"unknown", "print", "no title", "titul")
def guess_year(*sources: str) -> int | None:
for s in sources:
if not s:
continue
years = [int(y) for y in YEAR_RE.findall(s)]
if years:
return max(years)
return None
def extract(path: Path, max_pages: int = config.MAX_PDF_PAGES) -> dict:
"""Единая точка: текст и метаданные файла.
Никогда не бросает исключение — ошибка возвращается в поле ``error``.
"""
ext = path.suffix.lower()
res: dict = {"title": "", "author": "", "pages": None, "text": "",
"error": "", "toc": []}
handler = _HANDLERS.get(ext)
if handler is None:
res["error"] = "формат %s не поддерживается" % ext
return res
try:
got = handler(path, max_pages)
res.update({k: v for k, v in got.items() if v is not None})
except Exception as e: # noqa: BLE001 — устойчивость важнее диагностики
res["error"] = ("%s: %s" % (type(e).__name__, e))[:300]
res["text"] = clean_text(res.get("text") or "")[: config.MAX_TEXT_CHARS]
title = (res.get("title") or "").strip()
low = title.lower()
# Метаданные часто содержат мусор от конвертеров и имена исходников вёрстки —
# в таких случаях берём название из имени файла.
if (len(title) < 4 or any(b in low for b in BAD_TITLE_MARKS)
or re.search(r"\.(indb|indd|doc|docx|pdf|qxd|tex|fm|cdr|pmd|vp|rtf|"
r"ps|sla|odt)$", low)):
title = pretty_name(path.name)
res["title"] = title[:300]
res["author"] = (res.get("author") or "")[:200]
res["year"] = guess_year(path.name, res["title"], res["text"][:1500])
return res