"""Библиотека: разбор PDF/EPUB на главы, хранение в SQLite, гибридный поиск (FTS5 + эмбеддинги).""" import datetime as dt from html.parser import HTMLParser import os import re import sqlite3 import threading from pathlib import Path import fitz # PyMuPDF: PDF и EPUB одним API fitz.TOOLS.mupdf_display_errors(False) # шум про шрифты OCR-слоя в stderr — не ошибки разбора import numpy as np DATA = Path(os.environ.get("BOOKS_DATA", "/data")) FILES = DATA / "files" DB_PATH = DATA / "books.db" EMB_MODEL = os.environ.get("EMB_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") CHUNK = 1200 # символов на фрагмент поиска OVERLAP = 200 NO_TOC_PAGES = 20 # без оглавления — режем по 20 страниц _local = threading.local() _emb_lock = threading.Lock() _emb = None def db(): c = getattr(_local, "c", None) if c is None: DATA.mkdir(parents=True, exist_ok=True) c = sqlite3.connect(DB_PATH, timeout=60, check_same_thread=False) c.row_factory = sqlite3.Row c.execute("PRAGMA journal_mode=WAL") c.execute("PRAGMA foreign_keys=ON") c.executescript(SCHEMA) _local.c = c return c SCHEMA = """ CREATE TABLE IF NOT EXISTS book(id INTEGER PRIMARY KEY, title TEXT, author TEXT, lang TEXT, file TEXT, orig TEXT, fmt TEXT, pages INTEGER, added TEXT, status TEXT, note TEXT, last_ch INTEGER); CREATE TABLE IF NOT EXISTS chapter(id INTEGER PRIMARY KEY, book_id INTEGER REFERENCES book(id) ON DELETE CASCADE, idx INTEGER, title TEXT, level INTEGER, p1 INTEGER, p2 INTEGER, text TEXT, chars INTEGER, read INTEGER DEFAULT 0, read_at TEXT, UNIQUE(book_id, idx)); CREATE TABLE IF NOT EXISTS recap(chapter_id INTEGER PRIMARY KEY REFERENCES chapter(id) ON DELETE CASCADE, md TEXT, ai_md TEXT, updated TEXT); CREATE TABLE IF NOT EXISTS chunk(id INTEGER PRIMARY KEY, book_id INTEGER REFERENCES book(id) ON DELETE CASCADE, chapter_id INTEGER REFERENCES chapter(id) ON DELETE CASCADE, kind TEXT, page INTEGER, text TEXT, emb BLOB); CREATE INDEX IF NOT EXISTS chunk_book ON chunk(book_id, kind); CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(text, content='chunk', content_rowid='id', tokenize='unicode61 remove_diacritics 2'); CREATE TRIGGER IF NOT EXISTS chunk_ai AFTER INSERT ON chunk BEGIN INSERT INTO chunk_fts(rowid, text) VALUES (new.id, new.text); END; CREATE TRIGGER IF NOT EXISTS chunk_ad AFTER DELETE ON chunk BEGIN INSERT INTO chunk_fts(chunk_fts, rowid, text) VALUES ('delete', old.id, old.text); END; CREATE TABLE IF NOT EXISTS qa(id INTEGER PRIMARY KEY, book_id INTEGER REFERENCES book(id) ON DELETE CASCADE, chapter_idx INTEGER, kind TEXT, q TEXT, a TEXT, ts TEXT); """ def now(): return dt.datetime.now().isoformat(timespec="seconds") # ---------------------------------------------------------------- эмбеддинги def embedder(): global _emb with _emb_lock: if _emb is None: from fastembed import TextEmbedding _emb = TextEmbedding(EMB_MODEL, cache_dir=str(DATA / "models"), threads=2) return _emb def embed(texts): if not texts: return np.zeros((0, 384), dtype=np.float32) v = np.array(list(embedder().embed(texts, batch_size=32)), dtype=np.float32) v /= np.linalg.norm(v, axis=1, keepdims=True) + 1e-9 return v # ---------------------------------------------------------------- разбор книги def detect_lang(text): cyr = len(re.findall(r"[а-яёА-ЯЁ]", text[:20000])) lat = len(re.findall(r"[a-zA-Z]", text[:20000])) return "ru" if cyr > lat else "en" def clean(t): t = t.replace("­", "") # мягкие переносы t = re.sub(r"(\w)-\n(\w)", r"\1\2", t) # перенос слова по слогам в PDF t = re.sub(r"[ \t]+\n", "\n", t) t = re.sub(r"\n{3,}", "\n\n", t) return t.strip() CHAPTER_RE = re.compile(r"^\s*(глава|chapter|часть|part|раздел)\s+([0-9IVXLC]+|[а-яё]+)\b", re.I) MAX_AVG_PAGES = 45 # «главы» длиннее в среднем — это части книги, берём уровень глубже def _num(tok): tok = tok.upper() if tok.isdigit(): return int(tok) roman = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100} if tok and all(ch in roman for ch in tok): total = 0 for a, b in zip(tok, tok[1:] + " "): total += -roman[a] if b in roman and roman[b] > roman[a] else roman[a] return total return None def headings_from_pages(texts): """Без оглавления: страницы, которые начинаются с «Глава N» / «Chapter N». Колонтитулы повторяют «Глава N» на каждой странице, а страницы оглавления перечисляют все главы подряд, поэтому: берём первое появление каждого номера и самую длинную возрастающую по номеру цепочку (по порядку страниц).""" found, seen = [], set() for i, t in enumerate(texts): head = [l.strip() for l in t.splitlines()[:4] if l.strip()] for j, l in enumerate(head[:3]): m = CHAPTER_RE.match(l) if m and len(l) < 90: kind, n = m.group(1).lower(), _num(m.group(2)) if kind not in ("глава", "chapter") or n is None or n in seen: break seen.add(n) title = l if len(l) > 12 or j + 1 >= len(head) else f"{l}. {head[j + 1][:70]}" title = re.sub(r"\.{2,}", ".", title) found.append((n, i + 1, title)) break # самая длинная возрастающая подпоследовательность по номеру главы (O(n²), глав немного) best, prev = [1] * len(found), [-1] * len(found) for a in range(len(found)): for b in range(a): if found[b][0] < found[a][0] and best[b] + 1 > best[a]: best[a], prev[a] = best[b] + 1, b if not found: return [] k = max(range(len(found)), key=best.__getitem__) chain = [] while k >= 0: chain.append(found[k]) k = prev[k] chain.reverse() return [[1, title, page] for _, page, title in chain] if len(chain) >= 3 else [] def plan_chapters(toc, pages, texts=None): """Из оглавления — главы (верхний уровень, если он не из огромных «частей»). [(title, level, p1, p2)].""" toc = [(lvl, (title or "").strip(), max(1, min(pages, p))) for lvl, title, p, *_ in toc if p and p > 0] if not toc and texts: toc = [tuple(x) for x in headings_from_pages(texts)] if toc: lvl1 = [t for t in toc if t[0] == 1] lvl2 = [t for t in toc if t[0] <= 2] use = lvl1 if len(lvl1) >= 3 and (pages / len(lvl1) <= MAX_AVG_PAGES or len(lvl2) <= len(lvl1)) else lvl2 use = sorted({(p, i): (lvl, title, p) for i, (lvl, title, p) in enumerate(use)}.items()) use = [v for _, v in use] out = [] if use and use[0][2] > 1: out.append(("Начало книги", 1, 1, use[0][2] - 1)) for i, (lvl, title, p) in enumerate(use): p2 = use[i + 1][2] - 1 if i + 1 < len(use) else pages out.append((title or f"Часть {i + 1}", lvl, p, max(p, p2))) # слишком мелкие подряд (одна страница) оставляем как есть — это видно в оглавлении return out return [(f"Страницы {p}–{min(pages, p + NO_TOC_PAGES - 1)}", 1, p, min(pages, p + NO_TOC_PAGES - 1)) for p in range(1, pages + 1, NO_TOC_PAGES)] # ---------------------------------------------------------------- EPUB: свой разбор (у PyMuPDF нет оглавления EPUB 3) EPUB_PAGE = 2000 # у EPUB нет страниц — «страница» ≈ 2000 символов, для ссылок [с. N] class _Text(HTMLParser): BLOCK = {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "pre", "blockquote", "section", "dt", "dd"} def __init__(self): super().__init__(convert_charrefs=True) self.out, self.skip = [], 0 def handle_starttag(self, tag, attrs): if tag in ("script", "style", "head"): self.skip += 1 elif tag in self.BLOCK: self.out.append("\n\n") def handle_endtag(self, tag): if tag in ("script", "style", "head"): self.skip = max(0, self.skip - 1) elif tag in self.BLOCK: self.out.append("\n\n") def handle_data(self, d): if not self.skip: self.out.append(d) def html_text(raw): t = _Text() t.feed(raw) return clean(re.sub(r"[ \t\r\f\v]*\n[ \t\r\f\v]*", "\n", re.sub(r"[ \t]+", " ", "".join(t.out)))) def parse_epub(path): """→ (title, author, [(глава, уровень, [тексты «страниц»])]). Главы — по оглавлению (nav/NCX), текст — по spine.""" import posixpath import urllib.parse import xml.etree.ElementTree as ET import zipfile z = zipfile.ZipFile(path) tag = lambda e: e.tag.rsplit("}", 1)[-1] cont = ET.fromstring(z.read("META-INF/container.xml")) opf_path = next(e.get("full-path") for e in cont.iter() if tag(e) == "rootfile") base = posixpath.dirname(opf_path) norm = lambda href, rel=base: posixpath.normpath(posixpath.join(rel, urllib.parse.unquote(href.split("#")[0]))) opf = ET.fromstring(z.read(opf_path)) meta = {tag(e): (e.text or "").strip() for e in opf.iter() if tag(e) in ("title", "creator") and (e.text or "").strip()} manifest, nav_href, ncx_href = {}, None, None for e in opf.iter(): if tag(e) == "item": manifest[e.get("id")] = e.get("href") if "nav" in (e.get("properties") or "").split(): nav_href = e.get("href") if e.get("media-type") == "application/x-dtbncx+xml": ncx_href = e.get("href") spine = [norm(manifest[e.get("idref")]) for e in opf.iter() if tag(e) == "itemref" and e.get("idref") in manifest] titles = {} # файл spine → (название, уровень) первой записи оглавления def add(href, title, lvl, rel): f = norm(href, rel) if title and f not in titles: titles[f] = (title.strip(), lvl) try: if nav_href: rel = posixpath.dirname(norm(nav_href)) nav = ET.fromstring(z.read(norm(nav_href))) def walk(ol, lvl): for li in ol: if tag(li) != "li": continue a = next((x for x in li.iter() if tag(x) == "a"), None) if a is not None and a.get("href"): add(a.get("href"), "".join(a.itertext()), lvl, rel) for sub in li: if tag(sub) == "ol": walk(sub, lvl + 1) for n in nav.iter(): if tag(n) == "nav" and "toc" in (n.get("{http://www.idpf.org/2007/ops}type") or "toc"): for ol in n: if tag(ol) == "ol": walk(ol, 1) break if not titles and ncx_href: rel = posixpath.dirname(norm(ncx_href)) ncx = ET.fromstring(z.read(norm(ncx_href))) def walkp(node, lvl): for np_ in node: if tag(np_) != "navPoint": continue label = next(("".join(x.itertext()) for x in np_.iter() if tag(x) == "text"), "") src = next((x.get("src") for x in np_ if tag(x) == "content"), None) if src: add(src, label, lvl, rel) walkp(np_, lvl + 1) for n in ncx.iter(): if tag(n) == "navMap": walkp(n, 1) except (KeyError, ET.ParseError): titles = {} # верхний уровень, если его достаточно — иначе первые два lvl1 = [f for f, (t, l) in titles.items() if l == 1] keep_lvl = 1 if len(lvl1) >= 3 else 2 chapters = [] for f in spine: try: text = html_text(z.read(f).decode("utf-8", "replace")) except KeyError: continue t = titles.get(f) if not chapters or (t and t[1] <= keep_lvl) or (not titles and text): first = next((l for l in text.splitlines() if l.strip()), "")[:80] chapters.append([t[0] if t else (first or f"Часть {len(chapters) + 1}"), t[1] if t else 1, []]) if text: chapters[-1][2].append(text) out = [] for title, lvl, texts in chapters: body = "\n\n".join(texts) if not body.strip(): continue pages, buf = [], "" for para in re.split(r"\n\s*\n", body): if buf and len(buf) + len(para) > EPUB_PAGE: pages.append(buf) buf = "" buf = (buf + "\n\n" + para) if buf else para if buf: pages.append(buf) out.append((title, lvl, pages)) return meta.get("title", ""), meta.get("creator", ""), out def chunks_of(page_texts, p1): """Фрагменты для поиска с номером страницы начала.""" out = [] buf, start = "", p1 for i, t in enumerate(page_texts): page = p1 + i for para in re.split(r"\n\s*\n", t): para = para.strip() if not para: continue if not buf: start = page if len(buf) + len(para) > CHUNK and buf: out.append((start, buf)) buf = buf[-OVERLAP:] + "\n" + para start = page else: buf = (buf + "\n\n" + para) if buf else para if buf.strip(): out.append((start, buf)) return out def import_book(book_id): """Фоновая обработка загруженного файла: главы, текст, фрагменты, эмбеддинги.""" c = db() b = c.execute("SELECT * FROM book WHERE id=?", (book_id,)).fetchone() try: if b["fmt"] == "epub": et, ea, eps = parse_epub(FILES / b["file"]) texts, chapters = [], [] for ctitle, lvl, ptexts in eps: chapters.append((ctitle, lvl, len(texts) + 1, len(texts) + len(ptexts))) texts += ptexts pages = len(texts) meta = {"title": et, "author": ea} else: doc = fitz.open(FILES / b["file"]) if doc.needs_pass: raise ValueError("файл защищён паролем") pages = doc.page_count texts = [clean(doc[i].get_text("text")) for i in range(pages)] meta = doc.metadata or {} chapters = plan_chapters(doc.get_toc(simple=True), pages, texts) total = sum(len(t) for t in texts) mt = (meta.get("title") or "").strip() title = b["title"] or (mt if len(mt) > 2 and not mt.lower().startswith(("untitled", "microsoft")) else "") \ or Path(b["orig"] or b["file"]).stem author = (meta.get("author") or "").strip() note = "" if total < pages * 80: note = "Похоже на скан: текста почти нет (распознавание пока не поддерживается)." if not pages or not chapters: raise ValueError("в файле не нашлось текста") c.execute("DELETE FROM chapter WHERE book_id=?", (book_id,)) all_chunks = [] for idx, (ctitle, lvl, p1, p2) in enumerate(chapters, 1): ptexts = texts[p1 - 1:p2] body = "\n\n".join(f"[[p{p1 + i}]]\n{t}" for i, t in enumerate(ptexts)) cur = c.execute("INSERT INTO chapter(book_id, idx, title, level, p1, p2, text, chars) VALUES (?,?,?,?,?,?,?,?)", (book_id, idx, ctitle, lvl, p1, p2, body, sum(len(t) for t in ptexts))) for page, text in chunks_of(ptexts, p1): all_chunks.append((cur.lastrowid, page, text)) c.execute("UPDATE book SET title=?, author=?, lang=?, pages=?, note=?, status='indexing' WHERE id=?", (title, author, detect_lang("".join(texts[:50])), pages, note, book_id)) c.commit() for i in range(0, len(all_chunks), 256): part = all_chunks[i:i + 256] vecs = embed([t for _, _, t in part]) c.executemany("INSERT INTO chunk(book_id, chapter_id, kind, page, text, emb) VALUES (?,?,?,?,?,?)", [(book_id, ch, "book", pg, t, v.tobytes()) for (ch, pg, t), v in zip(part, vecs)]) c.commit() c.execute("UPDATE book SET status='ready' WHERE id=?", (book_id,)) c.commit() except Exception as e: # отдаём ошибку в интерфейс, не роняем приложение c.rollback() c.execute("UPDATE book SET status='error', note=? WHERE id=?", (f"Не удалось разобрать: {e}", book_id)) c.commit() def add_book(filename, data): FILES.mkdir(parents=True, exist_ok=True) ext = Path(filename).suffix.lower() if ext not in (".pdf", ".epub"): raise ValueError("Нужен PDF или EPUB") c = db() bid = c.execute("INSERT INTO book(orig, fmt, added, status) VALUES (?,?,?, 'parsing')", (Path(filename).name, ext[1:], now())).lastrowid name = f"{bid}{ext}" (FILES / name).write_bytes(data) c.execute("UPDATE book SET file=? WHERE id=?", (name, bid)) c.commit() threading.Thread(target=import_book, args=(bid,), daemon=True).start() return bid def delete_book(bid): c = db() b = c.execute("SELECT file FROM book WHERE id=?", (bid,)).fetchone() if b and b["file"]: (FILES / b["file"]).unlink(missing_ok=True) c.execute("DELETE FROM book WHERE id=?", (bid,)) c.commit() # ---------------------------------------------------------------- рекапы в индекс def index_recap(chapter_id): c = db() ch = c.execute("SELECT * FROM chapter WHERE id=?", (chapter_id,)).fetchone() r = c.execute("SELECT md FROM recap WHERE chapter_id=?", (chapter_id,)).fetchone() c.execute("DELETE FROM chunk WHERE chapter_id=? AND kind='recap'", (chapter_id,)) if r and (r["md"] or "").strip(): text = f"Рекап главы «{ch['title']}»:\n{r['md']}" v = embed([text])[0] c.execute("INSERT INTO chunk(book_id, chapter_id, kind, page, text, emb) VALUES (?,?,?,?,?,?)", (ch["book_id"], chapter_id, "recap", ch["p1"], text, v.tobytes())) c.commit() # ---------------------------------------------------------------- поиск def fts_query(q): words = [w for w in re.findall(r"\w+", q.lower()) if len(w) >= 2] out = [] for w in words: stem = re.sub(r"(ами|ями|ов|ев|ей|ий|ый|ой|ая|яя|ое|ее|ые|ие|ам|ям|ах|ях|ом|ем|а|я|ы|и|у|ю|е|о)$", "", w) \ if re.fullmatch(r"[а-яё]{5,}", w) else w out.append(f'"{stem}"*') return " OR ".join(out) def search(q, book_id=None, k=8): """Гибрид: BM25 (FTS5) + косинус эмбеддингов, слияние по рангам (RRF).""" c = db() where, args = ("AND c.book_id=?", [book_id]) if book_id else ("", []) ranks = {} fq = fts_query(q) if fq: rows = c.execute(f"SELECT c.id FROM chunk_fts f JOIN chunk c ON c.id=f.rowid WHERE chunk_fts MATCH ? {where} " f"ORDER BY bm25(chunk_fts) LIMIT 40", [fq] + args).fetchall() for r, row in enumerate(rows): ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r) rows = c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE c.emb IS NOT NULL {where}", args).fetchall() if rows: qv = embed([q])[0] m = np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1) sims = m @ qv for r, i in enumerate(np.argsort(-sims)[:40]): ranks[rows[i]["id"]] = ranks.get(rows[i]["id"], 0) + 1 / (60 + r) top = sorted(ranks, key=ranks.get, reverse=True)[:k] if not top: return [] res = c.execute(f"SELECT c.*, ch.idx AS ch_idx, ch.title AS ch_title, b.title AS book_title FROM chunk c " f"JOIN chapter ch ON ch.id=c.chapter_id JOIN book b ON b.id=c.book_id " f"WHERE c.id IN ({','.join('?' * len(top))})", top).fetchall() order = {cid: i for i, cid in enumerate(top)} return sorted(res, key=lambda r: order[r["id"]]) def find_selection(book_id, chapter_idx, text): """Окружение выделенного фрагмента в главе — для точного перевода/объяснения.""" c = db() ch = c.execute("SELECT text FROM chapter WHERE book_id=? AND idx=?", (book_id, chapter_idx)).fetchone() if not ch: return "" body = re.sub(r"\[\[p\d+\]\]\n", "", ch["text"]) probe = re.sub(r"\s+", " ", text.strip())[:60] flat = re.sub(r"\s+", " ", body) i = flat.find(probe) if i < 0: return "" return flat[max(0, i - 1500): i + len(text) + 1500]