Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""Выгрузка каталога: 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=>({'&':'&','<':'<','>':'>','"':'"'}[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"),
|
||
}
|