Каталогизатор документов: разбор папки, тезисы, поиск, 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:
2026-08-30 19:20:29 +03:00
commit 49f91247c7
30 changed files with 3934 additions and 0 deletions

301
catalogizer/report.py Normal file
View File

@@ -0,0 +1,301 @@
"""Выгрузка каталога: Markdown, HTML с поиском, JSON, CSV."""
from __future__ import annotations
import csv
import html
import json
from datetime import datetime
from pathlib import Path
from . import config, db
def fetch_all(con) -> list[dict]:
rows = con.execute(
"SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.size, d.lang,"
" d.kind, d.folder, d.rel_path, d.path, d.description, d.theses,"
" d.keywords, d.scanned, d.error, u.keywords AS user_keywords"
" FROM docs d LEFT JOIN user_keywords u ON u.path = d.path"
" ORDER BY d.folder, d.title").fetchall()
out = []
for r in rows:
d = dict(r)
d["theses"] = json.loads(d["theses"] or "[]")
d["keywords"] = json.loads(d["keywords"] or "[]")
d["user_keywords"] = json.loads(d["user_keywords"] or "[]")
out.append(d)
return out
def _size(n: int) -> str:
for unit in ("Б", "КБ", "МБ", "ГБ"):
if n < 1024:
return "%.0f %s" % (n, unit)
n /= 1024.0
return "%.1f ТБ" % n
# --------------------------------------------------------------------------
# Markdown
# --------------------------------------------------------------------------
def to_markdown(docs: list[dict], root: str, path: Path) -> Path:
by_folder: dict[str, list[dict]] = {}
for d in docs:
by_folder.setdefault(d["folder"], []).append(d)
lines = [
"# Каталог библиотеки",
"",
"Источник: `%s` " % root,
"Документов: **%d**, разделов: **%d** " % (len(docs), len(by_folder)),
"Составлен: %s" % datetime.now().strftime("%d.%m.%Y %H:%M"),
"",
"## Разделы",
"",
]
for folder in sorted(by_folder):
anchor = folder.lower().replace(" ", "-").replace("\\", "-").replace(".", "")
lines.append("- [%s](#%s) — %d" % (folder, anchor, len(by_folder[folder])))
lines.append("")
for folder in sorted(by_folder):
lines += ["", "## %s" % folder, ""]
for d in sorted(by_folder[folder], key=lambda x: (x["title"] or "").lower()):
head = "### %s" % (d["title"] or d["rel_path"])
lines.append(head)
facts = [d["ext"].lstrip("."), d["kind"] or ""]
if d["author"]:
facts.append(d["author"])
if d["year"]:
facts.append(str(d["year"]))
if d["pages"]:
facts.append("%d с." % d["pages"])
facts.append(_size(d["size"]))
if d["scanned"]:
facts.append("скан")
lines.append("*%s*" % " · ".join(f for f in facts if f))
lines.append("")
lines.append(d["description"] or "")
if d["theses"]:
lines.append("")
lines.append("**Тезисы:**")
for t in d["theses"]:
lines.append("- %s" % t)
if d["keywords"]:
lines.append("")
lines.append("**Ключевые слова:** %s" % ", ".join(d["keywords"]))
if d["user_keywords"]:
lines.append("")
lines.append("**Свои ключевые слова:** %s"
% ", ".join(d["user_keywords"]))
lines.append("")
lines.append("`%s`" % d["rel_path"])
lines.append("")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
return path
# --------------------------------------------------------------------------
# HTML с поиском
# --------------------------------------------------------------------------
HTML_TEMPLATE = """<!doctype html>
<html lang="ru"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Каталог библиотеки</title>
<style>
:root{--bg:#fbfbfa;--fg:#1c1c1a;--mut:#6b6b66;--line:#e2e1dc;--card:#fff;--acc:#8a4b1f;--hl:#ffeaa7}
@media(prefers-color-scheme:dark){:root{--bg:#16171a;--fg:#e8e8e4;--mut:#9a9a94;--line:#2c2e33;--card:#1d1f23;--acc:#e0a06a;--hl:#5a4a12}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif}
header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
h1{margin:0 0 10px;font-size:19px;font-weight:650}
.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
input,select{padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font-size:15px}
#q{flex:1;min-width:240px}
.stat{color:var(--mut);font-size:13px;margin-top:8px}
main{padding:16px 20px 60px;max-width:1080px;margin:0 auto}
.doc{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:12px}
.doc h2{margin:0 0 4px;font-size:16px;font-weight:620;line-height:1.35}
.meta{color:var(--mut);font-size:12.5px;margin-bottom:8px}
.desc{margin:0 0 8px}
ul.th{margin:6px 0 8px;padding-left:20px}
ul.th li{margin:3px 0;color:var(--fg)}
.kw{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.kw span{background:var(--bg);border:1px solid var(--line);border-radius:20px;padding:2px 10px;font-size:12px;color:var(--mut);cursor:pointer}
.kw span:hover{color:var(--acc);border-color:var(--acc)}
.kw span.mine{color:var(--acc);border-color:var(--acc);font-weight:600}
.path{font:12px ui-monospace,Consolas,monospace;color:var(--mut);word-break:break-all;margin-top:8px}
mark{background:var(--hl);color:inherit;border-radius:2px}
.folder{margin:22px 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--acc);font-weight:650}
.empty{color:var(--mut);padding:40px 0;text-align:center}
button.more{background:none;border:1px solid var(--line);border-radius:8px;padding:8px 14px;color:var(--mut);cursor:pointer}
</style></head><body>
<header>
<h1>Каталог библиотеки</h1>
<div class="bar">
<input id="q" placeholder="Поиск по названию, тезисам, ключевым словам…" autofocus>
<select id="ext"><option value="">все форматы</option>__EXTS__</select>
<select id="fold"><option value="">все разделы</option>__FOLDERS__</select>
<select id="lang"><option value="">язык</option><option value="ru">рус</option><option value="en">англ</option></select>
</div>
<div class="stat" id="stat"></div>
</header>
<main id="list"></main>
<script>
const DOCS = __DATA__;
const ROOT = __ROOT__;
// Ссылка на файл: путь приходит из каталога уже абсолютным, поэтому здесь
// достаточно привести разделители к виду file:// (работает и в Windows,
// и в macOS, и в Linux).
function fileUrl(p){
let s = String(p).replace(/\\\\/g,'/');
if(!s.startsWith('/')) s = '/' + s; // Windows: /E:/папка/файл.pdf
return 'file://' + encodeURI(s);
}
DOCS.forEach(d => { d.mk = d.mk || [];
d._s = (d.t+" "+d.a+" "+d.d+" "+d.k.join(" ")+" "+d.mk.join(" ")+" "
+d.th.join(" ")+" "+d.p).toLowerCase(); });
const q=document.getElementById('q'), ext=document.getElementById('ext'),
fold=document.getElementById('fold'), lang=document.getElementById('lang'),
list=document.getElementById('list'), stat=document.getElementById('stat');
let shown=60;
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function hl(s,terms){s=esc(s);for(const t of terms){if(t.length<2)continue;
s=s.replace(new RegExp('('+t.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')','gi'),'<mark>$1</mark>');}return s;}
function terms(){return q.value.toLowerCase().split(/[\\s,;]+/).filter(w=>w.length>1);}
function score(d,ts){
let sc=0;
for(const t of ts){
if(!d._s.includes(t)) return -1; // все слова обязательны
if(d.t.toLowerCase().includes(t)) sc+=10;
if(d.mk.some(k=>k.toLowerCase().includes(t))) sc+=12; // свои слова важнее
if(d.k.some(k=>k.includes(t))) sc+=6;
if(d.d.toLowerCase().includes(t)) sc+=3;
sc+=1;
}
return sc;
}
function render(){
const ts=terms(), e=ext.value, f=fold.value, l=lang.value;
let res=[];
for(const d of DOCS){
if(e && d.e!==e) continue;
if(f && d.fold!==f) continue;
if(l && d.l!==l) continue;
const sc = ts.length? score(d,ts) : 0;
if(sc<0) continue;
res.push([sc,d]);
}
if(ts.length) res.sort((a,b)=>b[0]-a[0]);
stat.textContent = 'Найдено: '+res.length+' из '+DOCS.length;
const part=res.slice(0,shown);
let html='', lastF=null;
for(const [,d] of part){
if(!ts.length && d.fold!==lastF){ html+='<div class="folder">'+esc(d.fold)+'</div>'; lastF=d.fold; }
html+='<article class="doc"><h2>'+hl(d.t,ts)+'</h2><div class="meta">'+
esc([d.e.slice(1),d.kind,d.a,d.y||'',d.pg?d.pg+' с.':'',d.sz,d.sc?'скан':''].filter(Boolean).join(' · '))+
'</div><p class="desc">'+hl(d.d,ts)+'</p>';
if(d.th.length) html+='<ul class="th">'+d.th.map(t=>'<li>'+hl(t,ts)+'</li>').join('')+'</ul>';
if(d.mk.length || d.k.length){
html+='<div class="kw">'
+ d.mk.map(k=>'<span class="mine" onclick="q.value=this.textContent;render()">'+esc(k)+'</span>').join('')
+ d.k.map(k=>'<span onclick="q.value=this.textContent;render()">'+esc(k)+'</span>').join('')
+ '</div>';
}
html+='<div class="path"><a href="'+fileUrl(d.f)+'">'+esc(d.p)+'</a></div></article>';
}
if(res.length>shown) html+='<button class="more" onclick="shown+=100;render()">Показать ещё</button>';
if(!res.length) html='<div class="empty">Ничего не найдено</div>';
list.innerHTML=html;
}
let timer; q.oninput=()=>{clearTimeout(timer);shown=60;timer=setTimeout(render,120);};
ext.onchange=fold.onchange=lang.onchange=()=>{shown=60;render();};
render();
</script></body></html>
"""
def to_html(docs: list[dict], root: str, path: Path) -> Path:
data = [{
"t": d["title"] or d["rel_path"],
"a": d["author"] or "",
"y": d["year"] or "",
"pg": d["pages"] or 0,
"e": d["ext"],
"l": d["lang"] or "",
"kind": d["kind"] or "",
"f": d["path"],
"p": d["rel_path"],
"fold": d["folder"],
"d": d["description"] or "",
"th": d["theses"],
"k": d["keywords"],
"mk": d["user_keywords"],
"sz": _size(d["size"]),
"sc": 1 if d["scanned"] else 0,
} for d in docs]
exts = sorted({d["ext"] for d in docs})
folders = sorted({d["folder"] for d in docs})
# "</" в данных закрыло бы <script> раньше времени.
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
out = (HTML_TEMPLATE
.replace("__DATA__", payload)
.replace("__ROOT__", json.dumps(root))
.replace("__EXTS__", "".join(
'<option value="%s">%s</option>' % (e, html.escape(e.lstrip(".")))
for e in exts))
.replace("__FOLDERS__", "".join(
'<option value="%s">%s</option>' % (html.escape(f), html.escape(f[:70]))
for f in folders)))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(out, encoding="utf-8")
return path
# --------------------------------------------------------------------------
# JSON / CSV
# --------------------------------------------------------------------------
def to_json(docs: list[dict], path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(docs, ensure_ascii=False, indent=1), encoding="utf-8")
return path
def to_csv(docs: list[dict], path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
cols = ("title", "author", "year", "pages", "kind", "lang", "ext", "size",
"folder", "rel_path", "description")
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow(["Название", "Автор", "Год", "Страниц", "Тип", "Язык",
"Формат", "Размер", "Раздел", "Путь", "Описание",
"Ключевые слова", "Свои ключевые слова"])
for d in docs:
w.writerow([d.get(c, "") for c in cols]
+ [", ".join(d["keywords"]),
", ".join(d["user_keywords"])])
return path
def build_all(con, out_dir: Path | None = None) -> dict[str, Path]:
out_dir = Path(out_dir or config.OUT_DIR)
docs = fetch_all(con)
root = db.get_meta(con, "root", str(config.DEFAULT_ROOT))
return {
"html": to_html(docs, root, out_dir / "catalog.html"),
"md": to_markdown(docs, root, out_dir / "catalog.md"),
"json": to_json(docs, out_dir / "catalog.json"),
"csv": to_csv(docs, out_dir / "catalog.csv"),
}