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

302 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.
"""Выгрузка каталога: 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"),
}