import io import re import sys import html import zipfile import posixpath import urllib.parse from pathlib import Path import xml.etree.ElementTree as ET from html.parser import HTMLParser from typing import Tuple, List, Dict def count_words(text: str) -> int: """ Counts words in text across multiple languages, ignoring isolated punctuation. """ if not text: return 0 return len(re.findall(r"\b[\w]+(?:['’][\w]+)?\b", text)) class HTMLTextExtractor(HTMLParser): """ Strips HTML tags cleanly while preserving paragraph and heading breaks with newlines. Also extracts the first heading and/or tag for chapter naming. """ def __init__(self): super().__init__() self.text_parts: List[str] = [] self.ignore_tags = {"style", "script", "noscript"} self.ignore_depth = 0 self.heading: str | None = None self.in_heading = False self.heading_parts: List[str] = [] self.title: str | None = None self.in_title = False self.title_parts: List[str] = [] def handle_starttag(self, tag: str, attrs): t = tag.lower() if t in self.ignore_tags: self.ignore_depth += 1 elif t == "title": self.in_title = True self.title_parts = [] elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}: self.text_parts.append("\n\n") if not self.heading: self.in_heading = True self.heading_parts = [] elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}: self.text_parts.append("\n\n") elif t in {"br", "hr", "li", "tr"}: self.text_parts.append("\n") def handle_endtag(self, tag: str): t = tag.lower() if t in self.ignore_tags: self.ignore_depth = max(0, self.ignore_depth - 1) elif t == "title": self.in_title = False self.title = "".join(self.title_parts).strip() elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}: self.text_parts.append("\n\n") if self.in_heading: self.in_heading = False if not self.heading: self.heading = "".join(self.heading_parts).strip() elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}: self.text_parts.append("\n\n") elif t in {"li", "tr"}: self.text_parts.append("\n") def handle_data(self, data: str): if self.in_title: self.title_parts.append(data) if self.ignore_depth == 0 and not self.in_title: # Replace non-breaking spaces with standard space cleaned_data = data.replace("\xa0", " ") self.text_parts.append(cleaned_data) if self.in_heading: self.heading_parts.append(cleaned_data) def get_text(self) -> str: raw = "".join(self.text_parts) lines = [line.strip() for line in raw.split("\n")] clean = "\n".join(lines) return re.sub(r"\n{3,}", "\n\n", clean).strip() def parse_epub(file_bytes: bytes) -> Tuple[str, str, List[Dict[str, str]]]: """ Parses an EPUB file archive and extracts: - title: book title - author: creator/author - chapters: list of {"title": str, "content": str} """ try: z = zipfile.ZipFile(io.BytesIO(file_bytes)) except Exception as e: raise ValueError(f"Invalid EPUB archive: {e}") # 1. Locate root OPF file from META-INF/container.xml opf_path = None try: container_data = z.read("META-INF/container.xml") root = ET.fromstring(container_data) rootfile = root.find(".//{*}rootfile") if rootfile is not None: opf_path = rootfile.attrib.get("full-path") except Exception: pass if not opf_path: for name in z.namelist(): if name.lower().endswith(".opf"): opf_path = name break if not opf_path: raise ValueError("Invalid EPUB: no OPF file found") opf_path = opf_path.replace("\\", "/").lstrip("/") opf_dir = posixpath.dirname(opf_path) try: opf_content = z.read(opf_path) except KeyError: # Try lowercase matching name_map = {n.lower(): n for n in z.namelist()} if opf_path.lower() in name_map: opf_content = z.read(name_map[opf_path.lower()]) else: raise ValueError(f"OPF file not found in archive: {opf_path}") # 2. Extract metadata (title, author) title = "Untitled" author = "" opf_tree = None try: opf_tree = ET.fromstring(opf_content) title_el = opf_tree.find(".//{*}title") if title_el is not None and title_el.text: title = title_el.text.strip() creator_el = opf_tree.find(".//{*}creator") if creator_el is not None and creator_el.text: author = creator_el.text.strip() except Exception: opf_str = opf_content.decode("utf-8", errors="ignore") tm = re.search(r"<[^:]*:?title[^>]*>(.*?)</[^:]*:?title>", opf_str, re.I | re.S) if tm: title = re.sub(r"<[^>]+>", "", tm.group(1)).strip() cm = re.search(r"<[^:]*:?creator[^>]*>(.*?)</[^:]*:?creator>", opf_str, re.I | re.S) if cm: author = re.sub(r"<[^>]+>", "", cm.group(1)).strip() # 3. Parse Manifest (id -> href, media-type) manifest: Dict[str, Dict[str, str]] = {} if opf_tree is not None: for item in opf_tree.findall(".//{*}item"): iid = item.attrib.get("id") href = item.attrib.get("href") media_type = item.attrib.get("media-type", "").lower() if iid and href: manifest[iid] = {"href": href, "media_type": media_type} else: opf_str = opf_content.decode("utf-8", errors="ignore") for m in re.finditer(r'<item\s+[^>]*id=["\']([^"\']+)["\'][^>]*href=["\']([^"\']+)["\']', opf_str, re.I): manifest[m.group(1)] = {"href": m.group(2), "media_type": ""} # 4. Spine items in reading order spine_ids: List[str] = [] if opf_tree is not None: for itemref in opf_tree.findall(".//{*}itemref"): idref = itemref.attrib.get("idref") if idref: spine_ids.append(idref) else: opf_str = opf_content.decode("utf-8", errors="ignore") for m in re.finditer(r'<itemref\s+[^>]*idref=["\']([^"\']+)["\']', opf_str, re.I): spine_ids.append(m.group(1)) # Fallback if spine is empty: find all html/xhtml files in manifest candidate_paths: List[str] = [] if spine_ids: for idref in spine_ids: item = manifest.get(idref) if item: candidate_paths.append(item["href"]) else: for item in manifest.values(): href = item["href"] if href.lower().endswith((".xhtml", ".html", ".htm")): candidate_paths.append(href) namelist_map = {name.lower(): name for name in z.namelist()} chapters: List[Dict[str, str]] = [] chapter_num = 1 for href in candidate_paths: unquoted = urllib.parse.unquote(href) rel_path = posixpath.normpath(posixpath.join(opf_dir, unquoted)) if opf_dir else unquoted rel_path = rel_path.replace("\\", "/").lstrip("/") actual_path = None if rel_path in z.namelist(): actual_path = rel_path elif rel_path.lower() in namelist_map: actual_path = namelist_map[rel_path.lower()] if not actual_path: continue try: raw_bytes = z.read(actual_path) try: html_str = raw_bytes.decode("utf-8") except UnicodeDecodeError: html_str = raw_bytes.decode("latin-1", errors="replace") except Exception: continue parser = HTMLTextExtractor() parser.feed(html_str) text = parser.get_text() # Skip chapters with little or no readable text if count_words(text) < 1: continue ch_title = parser.heading or parser.title or f"Chapter {chapter_num}" # If ch_title is identical to book title or very long, format nicely if len(ch_title) > 120: ch_title = f"Chapter {chapter_num}" chapters.append({"title": ch_title, "content": text}) chapter_num += 1 if not chapters: chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}] return title, author, chapters def split_long_text(text: str, target_words: int = 3000) -> List[str]: """ Splits text into chunks of approximately target_words on paragraph boundaries. """ paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()] if not paragraphs: return [text.strip()] if text.strip() else [] chunks: List[str] = [] current_paras: List[str] = [] current_count = 0 for p in paragraphs: w_count = count_words(p) # If single paragraph is massive (> target_words * 1.5), split by words if w_count > target_words * 1.5: if current_paras: chunks.append("\n\n".join(current_paras)) current_paras = [] current_count = 0 words = p.split() for i in range(0, len(words), target_words): chunk = " ".join(words[i : i + target_words]) if chunk.strip(): chunks.append(chunk) continue if current_count + w_count > target_words and current_count > 0: chunks.append("\n\n".join(current_paras)) current_paras = [p] current_count = w_count else: current_paras.append(p) current_count += w_count if current_paras: chunks.append("\n\n".join(current_paras)) return chunks def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]: """ Parses plain text or Markdown into readable chapters. Splits by explicit chapter markers (e.g. 'Chapter X', '# Chapter') or chunks of ~3000 words. """ content = content.replace("\r\n", "\n").replace("\r", "\n").strip() if not content: return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}] lines = content.split("\n") first_line = lines[0].strip() # Extract title from first line or # Heading / <h1...> if default or generic README if title == "Untitled" or title.lower().startswith("readme"): if first_line.startswith("# "): title = first_line.lstrip("# ").strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() elif first_line.lower().startswith("title:"): title = first_line.split(":", 1)[1].strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() else: h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", content[:1500], re.IGNORECASE) if h1_match: detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip() if detected: title = detected # Extract author from next line if missing if not author: lines = content.split("\n") if lines and lines[0].lower().startswith(("author:", "by:")): author = lines[0].split(":", 1)[1].strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() def detect_text_headings(content: str) -> List[Dict]: """ Detects section and chapter headings with high accuracy across Markdown, numbered sections, explicit keywords, bold titles, and Setext styles. """ lines = content.split("\n") line_starts = [] curr = 0 for l in lines: line_starts.append(curr) curr += len(l) + 1 kw_regex = re.compile( r"^[ \t]*(?:\*\*)?(?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Часть|ЧАСТЬ|Section|SECTION|Раздел|РАЗДЕЛ|Module|MODULE|Модуль|МОДУЛЬ|Unit|UNIT|Lesson|LESSON|Урок|УРОК|Topic|TOPIC|Тема|ТЕМА|Appendix|APPENDIX|Приложение|ПРИЛОЖЕНИЕ|Act|ACT|Scene|SCENE)[ \t]+(?:[0-9IVXLCDMivxlcdm\w\.\-]+)(?:[ \t]*[:\-–—\.][ \t]*[^\n\r*]+|[ \t]+[^\n\r*]+)?(?:\*\*)?[ \t]*$" ) headings = [] for i, line in enumerate(lines): trimmed = line.strip() if not trimmed: continue prev_empty = (i == 0) or (not lines[i - 1].strip()) next_empty = (i == len(lines) - 1) or (not lines[i + 1].strip()) # 1. Markdown # heading (# to #####) m_md = re.match(r"^#{1,5}[ \t]+(.+)$", trimmed) if m_md: clean_title = m_md.group(1).strip("#").strip() headings.append({ "line_idx": i, "start": line_starts[i], "title": clean_title, "type": "markdown", }) continue # 2. Setext heading: Title followed by === or --- if i + 1 < len(lines): next_line = lines[i + 1].strip() if prev_empty and len(trimmed) <= 80 and not trimmed.startswith(("-", "*", "#", "`")): if re.match(r"^={3,}$", next_line) or re.match(r"^-{3,}$", next_line): headings.append({ "line_idx": i, "start": line_starts[i], "title": trimmed, "type": "setext", }) continue # 3. Explicit keywords (Chapter 1, Глава 2, Module 3, etc.) if kw_regex.match(trimmed): clean_title = trimmed.strip("*").strip() headings.append({ "line_idx": i, "start": line_starts[i], "title": clean_title, "type": "keyword", }) continue # 4. Bold heading on standalone line: **Title** bold_m = re.match(r"^\*\*([A-ZА-ЯЁ0-9][^\n\r*]{2,70})\*\*$", trimmed) if bold_m and prev_empty: headings.append({ "line_idx": i, "start": line_starts[i], "title": bold_m.group(1).strip(), "type": "bold", }) continue # 5. Numbered sections: e.g. 1. Title, 1.1 Title, 1. Title. num_m = re.match(r"^([0-9]{1,2}(?:\.[0-9]{1,2})*\.?|[0-9]{1,2}\))[ \t]+([A-ZА-ЯЁ][^\n\r]{2,70})$", trimmed) if num_m and prev_empty: is_list = False if i + 1 < len(lines): next_t = lines[i + 1].strip() if re.match(r"^[0-9]{1,2}(?:\.[0-9]{1,2})*\.?[ \t]+", next_t): is_list = True if not is_list and not trimmed.endswith((",", ";")): headings.append({ "line_idx": i, "start": line_starts[i], "title": trimmed, "type": "numbered", }) continue # 6. Standalone ALL CAPS line (e.g. INTRODUCTION TO NETWORKS) next_is_allcaps = (i + 1 < len(lines)) and bool(lines[i + 1].strip()) and lines[i + 1].strip().isupper() if prev_empty and not next_is_allcaps and 4 <= len(trimmed) <= 60 and not trimmed.endswith((",", ";")): if re.match(r"^[A-ZА-ЯЁ0-9\s:\-–—\.]+$", trimmed): letters = re.findall(r"[A-ZА-ЯЁ]", trimmed) words = trimmed.split() if len(letters) >= 4 and (len(words) >= 2 or len(trimmed) >= 6): headings.append({ "line_idx": i, "start": line_starts[i], "title": trimmed, "type": "allcaps", }) continue return headings def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]: """ Parses plain text or Markdown into readable chapters. Splits by intelligent heading detection (Markdown, numbered sections, keywords) or chunks of ~1000 words. """ content = content.replace("\r\n", "\n").replace("\r", "\n").strip() if not content: return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}] lines = content.split("\n") first_line = lines[0].strip() # Extract title from first line or # Heading / <h1...> if default or generic README if title == "Untitled" or title.lower().startswith("readme"): if first_line.startswith("# "): title = first_line.lstrip("# ").strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() elif first_line.lower().startswith("title:"): title = first_line.split(":", 1)[1].strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() else: h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", content[:1500], re.IGNORECASE) if h1_match: detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip() if detected: title = detected # Extract author from next line if missing if not author: lines = content.split("\n") if lines and lines[0].lower().startswith(("author:", "by:")): author = lines[0].split(":", 1)[1].strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() detected_headings = detect_text_headings(content) chapters: List[Dict[str, str]] = [] if detected_headings: # Pre-chapter intro text intro = content[: detected_headings[0]["start"]].strip() if intro and count_words(intro) > 10: is_ru = any(ord(c) > 127 for c in intro[:200]) chapters.append({"title": "Введение" if is_ru else "Introduction", "content": intro}) for i, h in enumerate(detected_headings): start = h["start"] end = detected_headings[i + 1]["start"] if i + 1 < len(detected_headings) else len(content) body = content[start:end].strip() if body: chapters.append({"title": h["title"], "content": body}) if not chapters: # No headings detected: split by ~1000 words chunks if long enough total_w = count_words(content) if total_w > 1200: chunks = split_long_text(content, target_words=1000) is_ru = any(ord(c) > 127 for c in content[:200]) prefix = "Часть" if is_ru else "Chapter" for idx, chunk in enumerate(chunks, start=1): chapters.append({"title": f"{prefix} {idx}", "content": chunk}) else: chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": content}] return title, author, chapters # --------------------------------------------------------------------------- # PDF Parsing & Chapter Extraction (pypdf, Bookmarks/Outline, Cisco CLI Blocks) # --------------------------------------------------------------------------- CLI_PROMPT_RE = re.compile( r"^(?:[A-Za-z0-9_.\-]+(?:\([a-zA-Z0-9_\-\.\s]+\))?[>#]|" r"[A-Za-z0-9_.\-]+@[A-Za-z0-9_.\-]+:[~/\w\.\-]*[$#]|" r"(?:[A-Z]):\\[^>\n\r]*>|" r"PS\s+[A-Z]:\\[^>\n\r]*>)" ) def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]: """ Recursively extracts bookmarks / outline entries from PDF. Returns a list of (title, 0-indexed page_number). """ entries: List[Tuple[str, int]] = [] def _traverse(items): for item in items: if isinstance(item, list): _traverse(item) else: try: title = getattr(item, "title", None) or (item.get("/Title") if hasattr(item, "get") else None) page_num = reader.get_destination_page_number(item) if title and page_num is not None: clean_title = str(title).strip() if clean_title: entries.append((clean_title, int(page_num))) except Exception: continue if outline: _traverse(outline) return entries def clean_page_lines(raw_pages_text: List[str]) -> List[str]: """ Filters running headers, footers, isolated page numbers, and copyright noise. """ if not raw_pages_text: return [] num_pages = len(raw_pages_text) top_candidates: Dict[str, int] = {} bottom_candidates: Dict[str, int] = {} for pt in raw_pages_text: lines = [l.strip() for l in pt.split("\n") if l.strip()] if not lines: continue if len(lines) >= 1: top_candidates[lines[0]] = top_candidates.get(lines[0], 0) + 1 bottom_candidates[lines[-1]] = bottom_candidates.get(lines[-1], 0) + 1 # Lines repeated on >= 20% of pages (min 3 pages) are running headers/footers threshold = max(3, int(num_pages * 0.20)) common_headers = {l for l, c in top_candidates.items() if c >= threshold} common_footers = {l for l, c in bottom_candidates.items() if c >= threshold} page_num_re = re.compile(r"^(?:page\s+)?\d+(?:\s*(?:of|/)\s*\d+)?$", re.IGNORECASE) url_re = re.compile(r"^(?:https?://|www\.)\S+$", re.IGNORECASE) copyright_re = re.compile(r"^(?:©|\(c\)|copyright)\s*\d{4}.*$", re.IGNORECASE) cleaned_pages: List[str] = [] for pt in raw_pages_text: lines = pt.split("\n") filtered: List[str] = [] n_lines = len(lines) for idx, l in enumerate(lines): stripped = l.strip() if not stripped: filtered.append("") continue is_top = (idx <= 2) is_bottom = (idx >= n_lines - 3) if (is_top or is_bottom) and page_num_re.match(stripped): continue if is_top and stripped in common_headers: continue if is_bottom and (stripped in common_footers or url_re.match(stripped) or copyright_re.match(stripped)): continue filtered.append(l) cleaned_pages.append("\n".join(filtered)) return cleaned_pages def format_cli_blocks(text: str) -> str: """ Detects Cisco IOS / CLI terminal sessions in text and wraps them in Markdown fenced code blocks (```text ... ```) for syntax highlighting and copying. """ if not text: return "" lines = text.split("\n") out: List[str] = [] in_cli = False cli_lines: List[str] = [] def is_likely_cli_line(line: str) -> bool: s = line.strip() if not s: return True if CLI_PROMPT_RE.match(s): return True if s.startswith(("%", "!", "--", ">>", "Enter configuration", "Building configuration", "Current configuration")): return True if len(re.findall(r"\S\s{2,}\S", line)) >= 2: return True if re.match(r"^(?:show|ip|no|interface|switchport|vlan|router|network|exit|end|enable|disable|configure|ping|traceroute|debug)\s+", s, re.IGNORECASE): return True return False for line in lines: s = line.strip() if not in_cli: if CLI_PROMPT_RE.match(s): in_cli = True cli_lines = [line] else: out.append(line) else: if is_likely_cli_line(line): cli_lines.append(line) else: while cli_lines and not cli_lines[-1].strip(): cli_lines.pop() if cli_lines: out.append("```text") out.extend(cli_lines) out.append("```") in_cli = False cli_lines = [] out.append(line) if in_cli and cli_lines: while cli_lines and not cli_lines[-1].strip(): cli_lines.pop() if cli_lines: out.append("```text") out.extend(cli_lines) out.append("```") return "\n".join(out) def parse_pdf(file_bytes: bytes, filename: str = "") -> Tuple[str, str, List[Dict[str, str]]]: """ Parses a PDF file into readable chapters: - Extracts title and author from metadata or filename. - Enforces Scanned PDF Guard (raises ValueError if total_words < 100). - Cleans repetitive headers, footers, and page numbers. - Wraps Cisco IOS / CLI terminal blocks in Markdown code blocks. - Splits chapters by PDF outline / bookmarks (Cisco Press, O'Reilly). - Fallback to heading regex or ~10-15 page chunks if no bookmarks exist. """ try: import pypdf except ImportError: vendor_dir = Path(__file__).resolve().parent / "vendor" if str(vendor_dir) not in sys.path: sys.path.insert(0, str(vendor_dir)) import pypdf try: reader = pypdf.PdfReader(io.BytesIO(file_bytes)) except Exception as e: raise ValueError(f"Не удалось открыть PDF документ: {e}") if reader.is_encrypted: try: reader.decrypt("") except Exception: raise ValueError("Этот PDF документ защищен паролем. Пожалуйста, снимите пароль для чтения.") num_pages = len(reader.pages) if num_pages == 0: raise ValueError("PDF документ не содержит страниц") # Metadata extraction meta = reader.metadata or {} meta_title = getattr(meta, "title", None) or (meta.get("/Title") if hasattr(meta, "get") else None) or "" meta_author = getattr(meta, "author", None) or (meta.get("/Author") if hasattr(meta, "get") else None) or "" stem = Path(filename).stem if filename else "Untitled Document" clean_title = str(meta_title).strip() if not clean_title or clean_title.lower() in {"untitled", "untitled document", "default", "temp", "output"}: title = stem else: title = clean_title author = str(meta_author).strip() # Page-by-page text extraction raw_pages_text: List[str] = [] for p in reader.pages: try: pt = p.extract_text() or "" except Exception: pt = "" raw_pages_text.append(pt) # Scanned PDF Guard (< 100 words) total_words = sum(count_words(t) for t in raw_pages_text) if total_words < 100: raise ValueError( "Этот PDF-документ содержит сканы или изображения без текстового слоя (OCR). " "Пожалуйста, используйте документ с распознанным текстом." ) # Clean noise lines (headers, footers, page numbers) cleaned_pages = clean_page_lines(raw_pages_text) # Apply CLI formatting to each page formatted_pages = [format_cli_blocks(p) for p in cleaned_pages] # Chapter splitting: # 1. Try outline / bookmarks outline_entries = extract_outline_entries(getattr(reader, "outline", None), reader) chapters: List[Dict[str, str]] = [] if outline_entries: valid_entries = [(t, p) for t, p in outline_entries if 0 <= p < num_pages] valid_entries.sort(key=lambda x: x[1]) # Filter duplicates pointing to the same page deduped_entries: List[Tuple[str, int]] = [] for t, p in valid_entries: if not deduped_entries or deduped_entries[-1][1] != p: deduped_entries.append((t, p)) if deduped_entries: first_p = deduped_entries[0][1] if first_p > 0: intro_content = "\n\n".join(formatted_pages[p].strip() for p in range(0, first_p) if formatted_pages[p].strip()) if count_words(intro_content) >= 20: is_ru = any(ord(c) > 127 for c in intro_content[:300]) chapters.append({ "title": "Введение" if is_ru else "Introduction", "content": intro_content }) for i, (entry_title, start_p) in enumerate(deduped_entries): end_p = deduped_entries[i + 1][1] if i + 1 < len(deduped_entries) else num_pages ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip()) if ch_content: chapters.append({ "title": entry_title, "content": ch_content }) # 2. Fallback: No outline found, check for page-level chapter headings if not chapters: chapter_heading_re = re.compile( r"^\s*(?:chapter|глава|part|часть|section|раздел|module|модуль)\s+([0-9ivxlcdm]+)[\s:.\-–—\n]+(.*)$", re.IGNORECASE ) detected_pages: List[Tuple[str, int]] = [] for p_idx, p_text in enumerate(formatted_pages): first_lines = [l.strip() for l in p_text.split("\n") if l.strip()][:3] for l in first_lines: m = chapter_heading_re.match(l) if m: h_title = l.strip("#").strip() detected_pages.append((h_title, p_idx)) break if detected_pages: if detected_pages[0][1] > 0: intro_content = "\n\n".join(formatted_pages[p].strip() for p in range(0, detected_pages[0][1]) if formatted_pages[p].strip()) if count_words(intro_content) >= 20: is_ru = any(ord(c) > 127 for c in intro_content[:300]) chapters.append({ "title": "Введение" if is_ru else "Introduction", "content": intro_content }) for i, (h_title, start_p) in enumerate(detected_pages): end_p = detected_pages[i + 1][1] if i + 1 < len(detected_pages) else num_pages ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip()) if ch_content: chapters.append({ "title": h_title, "content": ch_content }) # 3. Fallback: Group pages in chunks of 12-15 pages if not chapters: chunk_size = 12 sample_text = "".join(formatted_pages[:3]) is_ru = any(ord(c) > 127 for c in sample_text[:300]) prefix = "Страницы" if is_ru else "Pages" for start_p in range(0, num_pages, chunk_size): end_p = min(start_p + chunk_size, num_pages) ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip()) if ch_content: chapters.append({ "title": f"{prefix} {start_p + 1}–{end_p}", "content": ch_content }) if not chapters: full_content = "\n\n".join(p.strip() for p in formatted_pages if p.strip()) chapters = [{"title": title, "content": full_content}] return title, author, chapters