#!/usr/bin/env python3 """ Сервис для парсинга EPUB файлов с извлечением глав и их названий """ from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware import ebooklib from ebooklib import epub from bs4 import BeautifulSoup import io import os import tempfile import base64 import json as json_module import re import logging import uuid as uuid_module import chardet from datetime import datetime from typing import List, Dict, Optional, Tuple, Union, Any from pydantic import BaseModel import psycopg2 # Настройка логирования logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Импорт для подсчета токенов try: import tiktoken TIKTOKEN_AVAILABLE = True except ImportError: TIKTOKEN_AVAILABLE = False logger.warning("tiktoken не установлен, будет использован приблизительный расчет токенов") # Константы для магических чисел # Длины текста MIN_TEXT_LENGTH_FOR_TITLE_EXTRACTION: int = 10 TITLE_SEARCH_PREFIX_LENGTH: int = 500 MIN_TITLE_LENGTH: int = 5 MAX_TITLE_LENGTH_SHORT: int = 100 MAX_TITLE_LENGTH_MEDIUM: int = 150 MAX_TITLE_LENGTH_LONG: int = 200 MIN_TITLE_LENGTH_STRICT: int = 3 # Длины для оценки заголовков IDEAL_TITLE_LENGTH_MIN: int = 20 IDEAL_TITLE_LENGTH_MAX: int = 100 GOOD_TITLE_LENGTH_MIN: int = 10 GOOD_TITLE_LENGTH_MAX: int = 150 ACCEPTABLE_TITLE_LENGTH_MIN: int = 5 ACCEPTABLE_TITLE_LENGTH_MAX: int = 200 INFORMATIVE_TITLE_LENGTH: int = 30 MIN_TITLE_LENGTH_FOR_PENALTY: int = 10 # Оценки заголовков MIN_HEADING_SCORE_THRESHOLD: float = 40.0 MIN_H2_SCORE_THRESHOLD: float = 30.0 SCORE_LENGTH_IDEAL: float = 30.0 SCORE_LENGTH_GOOD: float = 20.0 SCORE_LENGTH_ACCEPTABLE: float = 10.0 SCORE_H1: float = 25.0 SCORE_H2: float = 20.0 SCORE_H3: float = 15.0 SCORE_H4: float = 10.0 SCORE_H5_PLUS: float = 5.0 SCORE_POSITION_FIRST: float = 20.0 SCORE_POSITION_SECOND: float = 15.0 SCORE_POSITION_THIRD: float = 10.0 SCORE_POSITION_TOP5: float = 5.0 SCORE_QUESTION_MARK: float = 15.0 SCORE_LONG_TITLE: float = 10.0 SCORE_CHAPTER_CLASS: float = 10.0 SCORE_NOT_IN_TOC_PENALTY: float = -5.0 SCORE_SHORT_TITLE_PENALTY: float = -10.0 SCORE_GENERIC_TITLE_PENALTY: float = -5.0 # Кодировка MIN_ENCODING_CONFIDENCE: float = 0.7 # Сноски MAX_FOOTNOTE_LENGTH: int = 500 FOOTNOTE_COMPARISON_LENGTH: int = 100 # Файлы MIN_BINARY_FILE_SIZE: int = 100 EPUB_SIGNATURE: bytes = b'PK' # Позиции заголовков MAX_POSITION_FOR_BONUS: int = 5 app = FastAPI(title="EPUB Parser API", version="1.0.0") # Добавляем CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) def clean_text_for_vectorization(text: str) -> str: """Очищает текст от символов, которые могут мешать векторизации. Args: text: Исходный текст для очистки. Returns: Очищенный текст без проблемных символов. """ if not text: return text # Удаляем HTML теги (более тщательно) text = re.sub(r'<[^>]+>', '', text) # Удаляем soft hyphens и другие проблемные символы # \xad - soft hyphen (мягкий перенос) # \u200b-\u200f - zero-width spaces # \ufeff - BOM # \u0600-\u0605 - арабские символы форматирования problematic_chars = [ '\xad', # Soft hyphen '\ufeff', # BOM '\u200b', # Zero-width space '\u200c', # Zero-width non-joiner '\u200d', # Zero-width joiner '\u200e', # Left-to-right mark '\u200f', # Right-to-left mark '\u2060', # Word joiner '\u2028', # Line separator '\u2029', # Paragraph separator ] for char in problematic_chars: text = text.replace(char, '') # Удаляем другие непечатаемые символы (кроме пробелов, табуляции, переносов) cleaned = [] for char in text: if char.isprintable() or char in ['\n', '\r', '\t', ' ']: cleaned.append(char) # Заменяем непечатаемые символы на пробел elif ord(char) < 32: continue # Пропускаем контрольные символы else: # Для других непечатаемых символов проверяем, не являются ли они проблемными code = ord(char) if 0x0600 <= code <= 0x0605: # Арабские символы форматирования continue elif 0x2000 <= code <= 0x200F: # Различные пробелы cleaned.append(' ') elif 0x2028 <= code <= 0x202F: # Разделители строк/параграфов cleaned.append(' ') else: # Оставляем остальные символы (могут быть валидными Unicode) cleaned.append(char) text = ''.join(cleaned) # Нормализуем пробелы (множественные пробелы -> один) text = re.sub(r' +', ' ', text) # Нормализуем переносы строк (множественные -> один) text = re.sub(r'\n{3,}', '\n\n', text) # Удаляем пробелы в начале и конце строк lines = [line.strip() for line in text.split('\n')] text = '\n'.join(line for line in lines if line) return text.strip() def count_tokens(text: str, model: str = "gpt-4") -> int: """Подсчитывает количество токенов в тексте для указанной модели ИИ. Использует tiktoken для точного подсчета токенов OpenAI моделей. Если tiktoken недоступен, использует приблизительный расчет: 1 токен ≈ 4 символа для английского текста 1 токен ≈ 1.5 символа для русского текста (среднее значение) Args: text: Текст для подсчета токенов. model: Название модели ИИ (по умолчанию "gpt-4"). Returns: Количество токенов в тексте. """ if not text: return 0 if TIKTOKEN_AVAILABLE: try: # Используем кодировку для указанной модели # Для gpt-4 и gpt-3.5-turbo используется cl100k_base encoding = tiktoken.encoding_for_model(model) return len(encoding.encode(text)) except Exception as e: logger.warning(f"Ошибка при подсчете токенов через tiktoken: {e}, используем приблизительный расчет") # Приблизительный расчет: учитываем смешанный текст (русский + английский) # Для русского текста: ~1.5 символа на токен # Для английского текста: ~4 символа на токен # Используем среднее значение ~2.5 символа на токен return int(len(text) / 2.5) def calculate_chapter_tokens(chapter_data: Dict[str, Any]) -> int: """Подсчитывает общее количество токенов для главы со всеми метаданными. Учитывает: - Текст главы - Название главы - Текст всех сносок - Метаданные (chapterId, chapterNumber, filePath) Args: chapter_data: Словарь с данными главы. Returns: Общее количество токенов для главы. """ total_tokens = 0 # Текст главы chapter_text = chapter_data.get('text', '') if chapter_text: total_tokens += count_tokens(chapter_text) # Название главы chapter_title = chapter_data.get('chapterTitle', '') if chapter_title: total_tokens += count_tokens(chapter_title) # Сноски footnotes = chapter_data.get('footnotes', []) for footnote in footnotes: footnote_text = footnote.get('text', '') if footnote_text: total_tokens += count_tokens(footnote_text) # Метаданные (chapterId, chapterNumber, filePath) # Формируем строку с метаданными для подсчета metadata_parts = [] if chapter_data.get('chapterId'): metadata_parts.append(f"ID: {chapter_data['chapterId']}") if chapter_data.get('chapterNumber'): metadata_parts.append(f"Номер: {chapter_data['chapterNumber']}") if chapter_data.get('filePath'): metadata_parts.append(f"Файл: {chapter_data['filePath']}") if metadata_parts: metadata_text = " ".join(metadata_parts) total_tokens += count_tokens(metadata_text) return total_tokens def clean_title(title: str) -> str: """Очищает название главы от номеров и лишних символов. Args: title: Исходное название главы. Returns: Очищенное название без номеров и лишних пробелов. """ if not title: return title # Убираем начальные номера (например, "20 Обратная сторона..." -> "Обратная сторона...") title = title.strip() # Убираем номера в начале строки (1-3 цифры, возможно с точкой или пробелом) title = re.sub(r'^\d{1,3}[.\s]+', '', title) # Убираем "Ch1" и подобные префиксы title = re.sub(r'^Ch\d+\s*', '', title) # Убираем множественные пробелы title = re.sub(r'\s+', ' ', title).strip() return title def extract_title_from_text(text: str) -> Optional[str]: """Извлекает название главы из текста, если оно там есть. Ищет паттерны типа "Глава N Название" в начале текста. Args: text: Текст главы для анализа. Returns: Название главы или None, если название не найдено. """ if not text or len(text) < MIN_TEXT_LENGTH_FOR_TITLE_EXTRACTION: return None # Ищем паттерны типа "Глава 3 Название" в первых N символах first_part = text[:TITLE_SEARCH_PREFIX_LENGTH].strip() # Паттерн: "Глава N Название" или "Глава N. Название" patterns = [ rf'Глава\s+\d+\s+([А-ЯЁ][^.!?]{{{MIN_TITLE_LENGTH},{MAX_TITLE_LENGTH_SHORT}}}?)(?:[.!?]|$)', rf'Глава\s+\d+\.\s+([А-ЯЁ][^.!?]{{{MIN_TITLE_LENGTH},{MAX_TITLE_LENGTH_SHORT}}}?)(?:[.!?]|$)', rf'Глава\s+\d+:\s+([А-ЯЁ][^.!?]{{{MIN_TITLE_LENGTH},{MAX_TITLE_LENGTH_SHORT}}}?)(?:[.!?]|$)', rf'Глава\s+\d+\s+«([^»]{{{MIN_TITLE_LENGTH},{MAX_TITLE_LENGTH_SHORT}}}?)»', rf'Глава\s+\d+\.\s+«([^»]{{{MIN_TITLE_LENGTH},{MAX_TITLE_LENGTH_SHORT}}}?)»', ] for pattern in patterns: match = re.search(pattern, first_part, re.IGNORECASE) if match: title = match.group(1).strip() if MIN_TITLE_LENGTH <= len(title) <= MAX_TITLE_LENGTH_MEDIUM: return clean_title(title) return None def score_heading( heading_text: str, tag_name: str, position: int, classes: List[str] ) -> float: """Оценивает заголовок по критериям пригодности для названия главы. Args: heading_text: Текст заголовка для оценки. tag_name: Название тега (h1-h6). position: Позиция заголовка в документе (0 = первый). classes: Список классов тега. Returns: Оценка от 0 до 100 (чем выше, тем лучше). """ score = 0.0 text_len = len(heading_text) # Базовые проверки - исключаем технические заголовки if heading_text.isdigit() or heading_text.startswith('Ch') or text_len < MIN_TITLE_LENGTH: return 0.0 # Длина текста (идеально 20-100 символов) if IDEAL_TITLE_LENGTH_MIN <= text_len <= IDEAL_TITLE_LENGTH_MAX: score += SCORE_LENGTH_IDEAL elif GOOD_TITLE_LENGTH_MIN <= text_len <= GOOD_TITLE_LENGTH_MAX: score += SCORE_LENGTH_GOOD elif ACCEPTABLE_TITLE_LENGTH_MIN <= text_len <= ACCEPTABLE_TITLE_LENGTH_MAX: score += SCORE_LENGTH_ACCEPTABLE # Уровень заголовка (h1 и h2 предпочтительнее) if tag_name == 'h1': score += SCORE_H1 elif tag_name == 'h2': score += SCORE_H2 elif tag_name == 'h3': score += SCORE_H3 elif tag_name == 'h4': score += SCORE_H4 else: score += SCORE_H5_PLUS # Позиция (первые заголовки предпочтительнее) if position == 0: score += SCORE_POSITION_FIRST elif position == 1: score += SCORE_POSITION_SECOND elif position == 2: score += SCORE_POSITION_THIRD elif position < MAX_POSITION_FOR_BONUS: score += SCORE_POSITION_TOP5 # Информативность (вопросы, длинные заголовки) if '?' in heading_text: score += SCORE_QUESTION_MARK if text_len > INFORMATIVE_TITLE_LENGTH: score += SCORE_LONG_TITLE # Классы (chapter_title, title и т.д. дают бонус) class_str = ' '.join(classes).lower() if 'chapter' in class_str or 'title' in class_str: score += SCORE_CHAPTER_CLASS if 'not_in_toc' in class_str: score += SCORE_NOT_IN_TOC_PENALTY # Штраф за слишком короткие или общие слова if text_len < MIN_TITLE_LENGTH_FOR_PENALTY: score += SCORE_SHORT_TITLE_PENALTY if heading_text.lower() in ['введение', 'вступление', 'предисловие', 'заключение', 'обучение']: score += SCORE_GENERIC_TITLE_PENALTY return max(0.0, score) def detect_encoding(content: bytes) -> str: """Определяет кодировку содержимого файла. Args: content: Байтовое содержимое файла для определения кодировки. Returns: Название кодировки (по умолчанию 'utf-8'). """ try: result = chardet.detect(content) if result and result['encoding']: confidence = result.get('confidence', 0) if confidence > MIN_ENCODING_CONFIDENCE: encoding = result['encoding'].lower() # Нормализуем названия кодировок if encoding in ['utf-8', 'utf8']: return 'utf-8' elif encoding in ['windows-1251', 'cp1251']: return 'windows-1251' elif encoding in ['iso-8859-1', 'latin1']: return 'iso-8859-1' return encoding except Exception as e: logger.warning(f"Ошибка определения кодировки: {e}") # Пробуем декодировать как UTF-8 try: content.decode('utf-8') return 'utf-8' except UnicodeDecodeError: pass # Пробуем Windows-1251 для русских текстов try: content.decode('windows-1251') return 'windows-1251' except UnicodeDecodeError: pass return 'utf-8' # По умолчанию def extract_footnotes(soup: BeautifulSoup) -> List[Dict[str, str]]: """Извлекает сноски и примечания из HTML содержимого главы. Args: soup: BeautifulSoup объект с HTML содержимым главы. Returns: Список словарей с информацией о сносках. Каждый словарь содержит: - id: идентификатор сноски - text: текст сноски - type: тип сноски (footnote, noteref, aside, class-based) """ footnotes: List[Dict[str, str]] = [] # 1. EPUB3 стандартные сноски (epub:type="footnote") footnote_elements = soup.find_all(attrs={'epub:type': 'footnote'}) for fn in footnote_elements: fn_id = fn.get('id', '') fn_text = fn.get_text(separator=' ', strip=True) if fn_text: footnotes.append({ 'id': fn_id, 'text': clean_text_for_vectorization(fn_text), 'type': 'footnote' }) # 2. Ссылки на сноски (epub:type="noteref") noteref_elements = soup.find_all(attrs={'epub:type': 'noteref'}) for ref in noteref_elements: ref_href = ref.get('href', '') ref_id = ref.get('id', '') ref_text = ref.get_text(strip=True) # Ищем соответствующую сноску по href if ref_href.startswith('#'): footnote_id = ref_href[1:] footnote_elem = soup.find(id=footnote_id) if footnote_elem: fn_text = footnote_elem.get_text(separator=' ', strip=True) if fn_text: footnotes.append({ 'id': footnote_id, 'ref_id': ref_id, 'ref_text': ref_text, 'text': clean_text_for_vectorization(fn_text), 'type': 'noteref' }) # 3. Теги