Каталогизатор документов: разбор папки, тезисы, поиск, 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:
349
catalogizer/extract.py
Normal file
349
catalogizer/extract.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""Извлечение текста и метаданных из файлов библиотеки."""
|
||||
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
|
||||
Reference in New Issue
Block a user