diff --git a/MEMORY.md b/MEMORY.md index 1e21e21..5751e78 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -23,6 +23,8 @@ - `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`. - `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта. - Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> Yandex (опциональный) -> DuckDuckGo`. +- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `PendingScanDraft` с полями `isbn`, `author`, `title`; пользователь может поправить их вручную или прислать фото оборота титульного листа. +- OCR оборота титульного листа вынесен в `home_library/providers/title_page.py`: используется `Pillow` для препроцессинга и системный `tesseract` (`rus+eng`) для распознавания текста; дальше срабатывают простые эвристики для `ISBN`, автора и названия. - Yandex-провайдер активируется только при `YANDEX_ENABLED=1` и установленных `playwright`+`playwright-stealth` (нужен `playwright install chromium`), иначе тихо возвращает `None`. - Общие паттерны чистки SERP-заголовков и извлечения автора вынесены в `home_library/providers/_serp_parser.py` и переиспользуются DDG и Yandex. - Если ни один провайдер не нашёл книгу, бот сохраняет `PendingIsbnHint` в `user_data["pending_isbn_hint"]`, просит подсказку у пользователя и повторяет поиск через `fetch_from_ddg_with_context` (DDG с `q = "{isbn} {hint}"`). `/cancel` сбрасывает ожидание подсказки. @@ -46,6 +48,15 @@ - Для тестов SQLite путь к БД подменяется через `monkeypatch` в `tests/conftest.py`. - Для новых фич придерживаться test-first подхода: сначала тесты, потом реализация. +## Git Workflow Notes + +- Большие наборы изменений сначала раскладывать по отдельным тематическим веткам, а не копить в одной длинной feature-ветке. +- По возможности держать одну пользовательскую или техническую задачу в одной ветке и в одном PR. +- Коммиты делать атомарными: один логический шаг, в идеале одно изменение в одном файле или в тесно связанном наборе файлов. +- Формат commit message: `feature(): <короткое описание>`, `fix(): <короткое описание>`, `refactor(): <короткое описание>`. +- В body коммита и описании PR писать понятное человеку объяснение: что изменилось, какую проблему это решает и зачем выбран именно такой вариант. +- Если новая ветка зависит от другой тематической ветки, явно сохранять эту зависимость в базе ветки, а не прятать ее в конфликтах при переносе. + ## Pending Architectural Work - Хэндлеры разбиты на `commands.py`, `callbacks.py`, `access.py`, `_helpers.py` и `main.py`. diff --git a/README.md b/README.md index b066a94..0264c53 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ Home Library - Telegram-бот для учета домашней библиот Обычный сценарий работы с ботом выглядит так: - Вы фотографируете штрихкод на задней стороне книги. -- Бот распознает ISBN по фотографии. -- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки. -- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию. +- Бот распознает ISBN по фотографии и сначала показывает черновик распознанных полей: `ISBN`, `Автор`, `Название`. +- Пользователь может исправить любое поле вручную перед поиском. +- Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год. +- Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки. - Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. - Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`. - Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки. @@ -22,6 +23,7 @@ Home Library - Telegram-бот для учета домашней библиот - отправьте `/start`, чтобы увидеть краткую справку - отправьте `/search <запрос>` или просто текстовое сообщение, чтобы найти книгу в каталоге - отправьте фото штрихкода, если хотите найти и добавить книгу по ISBN +- при необходимости пришлите фото оборота титульного листа, чтобы бот добрал автора, название и ISBN по выходным сведениям - используйте кнопки в боте, чтобы менять статус, оценку, комментарий, темы и местоположение - если вы админ, используйте `/add_access_to_library` для выдачи Telegram-доступа членам семьи - если вы админ, используйте `/export ` для выгрузки каталога @@ -43,6 +45,16 @@ python -m pip install --upgrade pip python -m pip install -r requirements.txt ``` +Для OCR оборота титульного листа дополнительно нужен системный `tesseract`. + +macOS: + +```bash +brew install tesseract tesseract-lang +``` + +Проверьте, что доступны языки `rus` и `eng`. Без `tesseract` бот всё равно будет работать, но OCR для оборота титульного листа будет недоступен. + Создайте файл `.env` в корне проекта и запишите в него следующее: ```dotenv @@ -84,6 +96,28 @@ python -m home_library сработает интерактивная подсказка, бот попросит название/автора и перезапустит поиск через DuckDuckGo. +## Фото Оборота Титульного Листа + +Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`. + +Подходящее фото обычно содержит выходные сведения, например: + +```text +Сью Алекс +System Design. Подготовка к сложному интервью. +СПб.: Питер, 2022 +ISBN 978-5-4461-1816-8 +ББК 32.973.2-02 +УДК 004.41 +``` + +Советы для OCR: + +- фотографируйте страницу целиком +- держите камеру ровно над страницей +- избегайте сильных теней и бликов +- лучше присылать именно оборот титульного листа, а не случайную первую страницу текста + ## Первый Запуск - `LIBRARY_BOT_TOKEN` обязателен всегда. Получить токен можно у [@BotFather](https://t.me/BotFather). @@ -191,7 +225,7 @@ python -m home_library ## Важно - `/search` и обычное текстовое сообщение ищут только по уже сохраненным книгам в каталоге домашней библиотеки -- внешний поиск используется только в сценарии добавления книги по фото штрихкода +- внешний поиск используется только в сценарии добавления книги по фото штрихкода или после подтверждения OCR-черновика - экспорт `/export db` отдает только `library.db`; `users.db` в экспорт не входит ## Файлы Данных diff --git a/home_library/interfaces/telegram/formatters.py b/home_library/interfaces/telegram/formatters.py index 7baf745..8c321eb 100644 --- a/home_library/interfaces/telegram/formatters.py +++ b/home_library/interfaces/telegram/formatters.py @@ -4,6 +4,7 @@ from home_library.config import BookEditableField, UserEditableField from home_library.domain.models import BookRecord, UserBookDataRecord +from home_library.interfaces.telegram.handlers._helpers import PendingScanDraft from home_library.storage.sqlite import get_user_by_id @@ -138,3 +139,20 @@ def get_field_label(field: str, user_id: int | None = None) -> str: return user_field.label return field + + +def format_scan_draft(draft: PendingScanDraft, *, from_verso: bool = False) -> str: + """Форматирует черновик распознанных полей для проверки пользователем.""" + title = "Распознал данные с оборота титульного листа:" if from_verso else "Что удалось распознать:" + isbn = escape(draft.isbn) if draft.isbn else "не найден" + author = escape(draft.author) if draft.author else "не найден" + book_title = escape(draft.title) if draft.title else "не найден" + tail = ( + "Всё верно?" + if from_verso + else ( + "Проверь данные перед поиском.\n" + "Если они неточные, можно исправить их вручную или прислать фото оборота титульного листа." + ) + ) + return f"{title}\n\nISBN: {isbn}\nАвтор: {author}\nНазвание: {book_title}\n\n{tail}" diff --git a/home_library/interfaces/telegram/handlers/__init__.py b/home_library/interfaces/telegram/handlers/__init__.py index eb36f24..a06ae19 100644 --- a/home_library/interfaces/telegram/handlers/__init__.py +++ b/home_library/interfaces/telegram/handlers/__init__.py @@ -30,6 +30,7 @@ handle_export_callback, handle_photo, handle_remove_access_callback, + handle_scan_draft_callback, handle_set_callback, ) @@ -86,6 +87,7 @@ "handle_export_callback", "handle_photo", "handle_remove_access_callback", + "handle_scan_draft_callback", "handle_set_callback", "log_unhandled_error", "main", diff --git a/home_library/interfaces/telegram/handlers/_helpers.py b/home_library/interfaces/telegram/handlers/_helpers.py index cb50359..14c2cff 100644 --- a/home_library/interfaces/telegram/handlers/_helpers.py +++ b/home_library/interfaces/telegram/handlers/_helpers.py @@ -22,6 +22,9 @@ logger = logging.getLogger(__name__) PENDING_ISBN_HINT_KEY = "pending_isbn_hint" +PENDING_SCAN_DRAFT_KEY = "pending_scan_draft" +SCAN_DRAFT_EDITING_KEY = "scan_draft_editing" +PENDING_BOOK_EDITING_KEY = "pending_book_editing" @dataclass(frozen=True) @@ -32,6 +35,27 @@ class PendingIsbnHint: created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) +@dataclass(slots=True) +class PendingScanDraft: + """Черновик данных книги, распознанных с фото до запуска поиска.""" + + token: str + isbn: str = "" + author: str = "" + title: str = "" + awaiting_verso_photo: bool = False + recognized_from_verso: bool = False + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass(slots=True) +class PendingBookEdit: + """Состояние ручной правки книги перед добавлением в каталог.""" + + field: str + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + EDIT_CALLBACK_MIN_PARTS = 3 OPTIONAL_USER_ID_INDEX = 3 SET_CALLBACK_MIN_PARTS = 5 diff --git a/home_library/interfaces/telegram/handlers/callbacks.py b/home_library/interfaces/telegram/handlers/callbacks.py index 5b0b9b3..955964b 100644 --- a/home_library/interfaces/telegram/handlers/callbacks.py +++ b/home_library/interfaces/telegram/handlers/callbacks.py @@ -2,9 +2,12 @@ import hashlib import logging +import re +from typing import cast import httpx from telegram import ( + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, @@ -12,14 +15,20 @@ ) from telegram.ext import ContextTypes +from home_library import config from home_library.domain.models import BookRecord, EditState -from home_library.interfaces.telegram.formatters import escape, format_book, get_field_label +from home_library.interfaces.telegram.formatters import escape, format_book, format_scan_draft, get_field_label from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers._helpers import ( EXPORT_CALLBACK_MIN_PARTS, + PENDING_BOOK_EDITING_KEY, PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, SET_CALLBACK_MIN_PARTS, + PendingBookEdit, PendingIsbnHint, + PendingScanDraft, _parse_edit_callback_data, _require_callback_query, _require_message, @@ -27,19 +36,24 @@ _send_export_document, _update_context_repr, ) -from home_library.interfaces.telegram.keyboards import edit_keyboard +from home_library.interfaces.telegram.keyboards import edit_keyboard, pending_book_keyboard, scan_draft_keyboard from home_library.providers.barcode import decode_barcode from home_library.providers.ddg import fetch_from_ddg_with_context from home_library.providers.lookup import fetch_book_by_isbn +from home_library.providers.title_page import parse_title_page_image from home_library.storage.sqlite import ( add_book, find_existing_book, get_book_by_id, get_users, + search_books, update_book_field, ) logger = logging.getLogger(__name__) +SCAN_CALLBACK_MIN_PARTS = 3 +SCAN_CALLBACK_WITH_FIELD_PARTS = 4 +ADD_BOOK_CALLBACK_WITH_FIELD_PARTS = 3 def _make_pending_book_token(book: BookRecord) -> str: @@ -48,6 +62,367 @@ def _make_pending_book_token(book: BookRecord) -> str: return hashlib.blake2s(token_source.encode("utf-8"), digest_size=8).hexdigest()[:12] +def _make_scan_draft_token(*, isbn: str, author: str, title: str) -> str: + """Строит токен для привязки callback'ов к текущему черновику скана.""" + token_source = f"{isbn}|{author}|{title}" + return hashlib.blake2s(token_source.encode("utf-8"), digest_size=8).hexdigest()[:12] + + +def _clear_scan_draft_state(user_data: dict[str, object]) -> None: + """Очищает временное состояние проверки распознанных полей.""" + user_data.pop(PENDING_SCAN_DRAFT_KEY, None) + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + + +def _parse_scan_callback_data(callback_data: str) -> tuple[str, str, str] | None: + """Парсит callback вида ``scan:action:field?:token``.""" + parts = callback_data.split(":") + if len(parts) == SCAN_CALLBACK_MIN_PARTS: + return parts[1], "", parts[2] + if len(parts) == SCAN_CALLBACK_WITH_FIELD_PARTS: + return parts[1], parts[2], parts[3] + return None + + +def _get_pending_scan_draft(user_data: dict[str, object], token: str) -> PendingScanDraft | None: + """Возвращает черновик скана, если callback относится к актуальному состоянию.""" + draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(draft, PendingScanDraft): + return None + if draft.token != token: + return None + return draft + + +async def _reply_with_scan_draft( + message: Message, + draft: PendingScanDraft, +) -> None: + """Показывает пользователю текущий черновик распознанных полей.""" + await message.reply_text( + format_scan_draft(draft, from_verso=draft.recognized_from_verso), + parse_mode="HTML", + reply_markup=scan_draft_keyboard(draft.token, from_verso=draft.recognized_from_verso), + ) + + +def _merge_title_page_into_draft(draft: PendingScanDraft, *, isbn: str, author: str, title: str) -> PendingScanDraft: + """Объединяет распознанные с оборота титула данные с текущим черновиком.""" + merged_isbn = isbn or draft.isbn + merged_author = author or draft.author + merged_title = title or draft.title + return PendingScanDraft( + token=_make_scan_draft_token(isbn=merged_isbn, author=merged_author, title=merged_title), + isbn=merged_isbn, + author=merged_author, + title=merged_title, + recognized_from_verso=True, + ) + + +def _get_pending_book(user_data: dict[str, object], token: str) -> BookRecord | None: + """Возвращает preview книги, если callback относится к актуальному состоянию.""" + pending_book = user_data.get("pending_book") + pending_book_token = user_data.get("pending_book_token") + if not isinstance(pending_book, BookRecord): + return None + if pending_book_token != token: + return None + return pending_book + + +def _build_pending_book(book: BookRecord, *, field: str, value: str) -> BookRecord: + """Возвращает копию preview книги с обновлённым полем.""" + return book.model_copy(update={field: value}) + + +def _build_pending_book_from_scan_draft(draft: PendingScanDraft) -> BookRecord: + """Собирает preview книги напрямую из подтверждённого scan draft.""" + return BookRecord( + title=draft.title, + author=draft.author, + isbn=re.sub(r"[^0-9Xx]", "", draft.isbn), + ) + + +def _merge_lookup_result_with_manual_fields(book_data: BookRecord, manual_book: BookRecord) -> BookRecord: + """Сохраняет вручную подтверждённые поля поверх данных провайдера.""" + return book_data.model_copy( + update={ + "isbn": manual_book.isbn or book_data.isbn, + "author": manual_book.author or book_data.author, + "title": manual_book.title or book_data.title, + }, + ) + + +async def _reply_with_pending_book_preview( + message: Message, + book: BookRecord, + user_data: dict[str, object], +) -> None: + """Показывает preview книги перед добавлением в каталог.""" + pending_book_token = _make_pending_book_token(book) + user_data["pending_book"] = book + user_data["pending_book_token"] = pending_book_token + await message.reply_text( + f"Найдена книга:\n\n{format_book(book)}\n\nДобавить в каталог?", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=pending_book_keyboard(pending_book_token), + ) + + +async def _show_added_book_result(query: CallbackQuery, book_id: int) -> None: + """Показывает финальное сообщение после успешного добавления книги.""" + book = get_book_by_id(book_id) + if book is None: + msg = ( + f"Книга только что добавлена (id={book_id}), " + f"но не может быть прочитана обратно из БД. " + f"Возможно, проблема с файлом базы данных." + ) + raise RuntimeError(msg) + + keyboard = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "✏️ Заполнить данные", + callback_data=f"edit:{book_id}:menu", + ), + ], + ], + ) + await query.edit_message_text( + f"✅ Книга добавлена в каталог (#{book_id}):\n\n{format_book(book)}", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=keyboard, + ) + + +async def _add_book_and_show_result(query: CallbackQuery, book_data: BookRecord) -> None: + """Добавляет книгу в БД и показывает финальную карточку.""" + try: + book_id = add_book(book_data) + except Exception: + logger.exception( + "Ошибка при добавлении книги title=%r в БД", + book_data.title, + ) + await query.edit_message_text( + "Не удалось добавить книгу в базу данных. Попробуйте позже.", + ) + return + + await _show_added_book_result(query, book_id) + + +async def _prompt_pending_book_field_edit( + query: CallbackQuery, + user_data: dict[str, object], + field: str, +) -> None: + """Переводит пользователя в режим ручной правки preview книги.""" + pending_book = user_data.get("pending_book") + if not isinstance(pending_book, BookRecord): + await query.edit_message_text("Данные книги устарели. Попробуй отправить фото заново.") + return + + current_value = { + "isbn": _build_field_edit_value(pending_book.isbn), + "author": _build_field_edit_value(pending_book.author), + "title": _build_field_edit_value(pending_book.title), + }[field] + user_data[PENDING_BOOK_EDITING_KEY] = PendingBookEdit(field=field) + await query.edit_message_text(current_value) + message = _require_callback_message(query) + await message.reply_text(_build_field_edit_instruction()) + + +async def _search_books_from_scan_draft(message: Message, query_text: str) -> None: + """Выполняет текстовый поиск, запущенный из черновика распознавания.""" + try: + results = search_books(query_text) + except Exception: + logger.exception("Ошибка при поиске книг из scan draft query=%r", query_text) + await message.reply_text("Произошла ошибка при поиске. Попробуйте позже.") + return + + if not results: + await message.reply_text(f"По запросу «{query_text}» ничего не найдено.") + return + + header = f"Найдено: {len(results)}" + if len(results) == config.SEARCH_LIMIT: + header += f" (показаны первые {config.SEARCH_LIMIT}, уточни запрос)" + + for index, book in enumerate(results): + if book.id is None: + msg = ( + f"Книга из результатов поиска не имеет id " + f"(title={book.title!r}, query={query_text!r}). " + f"Возможно, нарушена целостность БД." + ) + raise RuntimeError(msg) + + text = format_book(book) + if index == 0: + text = f"{header}\n\n{text}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("✏️ Редактировать", callback_data=f"edit:{book.id}:menu")]], + ) + await message.reply_text( + text, + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=keyboard, + ) + + +def _build_title_page_verso_prompt() -> str: + """Возвращает подсказку для фото оборота титульного листа.""" + return ( + "Пришли фото оборота титульного листа.\n\n" + "На этой странице обычно есть выходные сведения:\n" + "Сью Алекс\n" + "System Design. Подготовка к сложному интервью.\n" + "СПб.: Питер, 2022\n" + "ISBN 978-5-4461-1816-8\n" + "ББК 32.973.2-02\n" + "УДК 004.41\n\n" + "Постарайся сфотографировать страницу целиком, ровно и без сильных теней." + ) + + +def _build_field_edit_value(value: str) -> str: + """Возвращает текущее значение поля для удобного копирования.""" + return value.strip() or "не найден" + + +def _build_field_edit_instruction() -> str: + """Возвращает инструкцию для ручной правки поля.""" + return "Введи новое значение:\n(или /cancel для отмены)" + + +async def _answer_edit_in_progress(query: CallbackQuery) -> None: + """Сообщает, что нужно завершить активное редактирование поля.""" + await query.answer("Сначала заверши редактирование поля.", show_alert=True) + + +async def _prompt_scan_draft_field_edit( + query: CallbackQuery, + user_data: dict[str, object], + field: str, +) -> None: + """Переводит пользователя в режим ручного редактирования одного поля.""" + pending_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(pending_draft, PendingScanDraft): + await query.edit_message_text("Данные распознавания устарели. Отправь фото заново.") + return + + current_value = { + "isbn": _build_field_edit_value(pending_draft.isbn), + "author": _build_field_edit_value(pending_draft.author), + "title": _build_field_edit_value(pending_draft.title), + }[field] + user_data[SCAN_DRAFT_EDITING_KEY] = field + await query.edit_message_text(current_value) + message = _require_callback_message(query) + await message.reply_text(_build_field_edit_instruction()) + + +def _require_callback_message(query: CallbackQuery) -> Message: + """Возвращает callback message, если она доступна для ответов.""" + message = cast("Message | None", query.message) + if message is None: + raise RuntimeError("Callback scan draft не содержит message для продолжения поиска.") + return message + + +async def _continue_scan_draft( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_data: dict[str, object], + query: CallbackQuery, + draft: PendingScanDraft, +) -> None: + """Ищет данные книги по текущим полям scan draft.""" + manual_book = _build_pending_book_from_scan_draft(draft) + await _lookup_book_by_fields( + update, + context, + user_data, + query, + message=_require_callback_message(query), + manual_book=manual_book, + clear_scan_draft=True, + ) + + +async def _lookup_book_by_fields( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_data: dict[str, object], + query: CallbackQuery, + *, + message: Message, + manual_book: BookRecord, + clear_scan_draft: bool = False, +) -> None: + """Ищет информацию о книге по текущим вручную подтверждённым полям.""" + if not any((manual_book.isbn, manual_book.author, manual_book.title)): + await query.answer( + "Заполни хотя бы ISBN, автора или название перед поиском.", + show_alert=True, + ) + return + + if clear_scan_draft: + _clear_scan_draft_state(user_data) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + await query.edit_message_text("Ищу информацию о книге...") + + normalized_isbn = re.sub(r"[^0-9Xx]", "", manual_book.isbn) + if normalized_isbn: + await message.reply_text( + f"Ищу книгу по ISBN {escape(normalized_isbn)}...", + parse_mode="HTML", + ) + try: + book_data = await fetch_book_by_isbn(normalized_isbn) + except Exception: + logger.exception("Ошибка при поиске книги по ISBN %s из scan draft", normalized_isbn) + await message.reply_text( + f"Произошла ошибка при поиске книги по ISBN {normalized_isbn}.\nПопробуйте позже.", + ) + return + + if book_data: + merged_book = _merge_lookup_result_with_manual_fields(book_data, manual_book) + await _present_book_for_isbn(update, context, message, merged_book, merged_book.isbn or normalized_isbn) + return + + if manual_book.author or manual_book.title: + query_text = " ".join(part for part in [manual_book.author, manual_book.title] if part).strip() + await message.reply_text("По ISBN книга не нашлась, пробую поиск по автору и названию...") + await _search_books_from_scan_draft(message, query_text) + return + + user_data[PENDING_ISBN_HINT_KEY] = PendingIsbnHint(isbn=normalized_isbn) + await message.reply_text( + f"Книга с ISBN {normalized_isbn} не найдена.\n" + "Напиши название, автора или часть — попробую с подсказкой (или /cancel).", + ) + return + + query_text = " ".join(part for part in [manual_book.author, manual_book.title] if part).strip() + await message.reply_text("Ищу книгу по названию и автору...") + await _search_books_from_scan_draft(message, query_text) + + async def handle_export_callback( update: Update, _context: ContextTypes.DEFAULT_TYPE, @@ -131,10 +506,10 @@ async def handle_remove_access_callback( async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает фото со штрихкодом: распознаёт ISBN и ищет книгу. + """Обрабатывает фото: собирает черновик ISBN/автора/названия и запускает поиск позже. - Полный flow: фото → decode_barcode → fetch_book_by_isbn → - проверка дубликата → подтверждение добавления. + Полный flow: фото штрихкода/оборота титула → черновик распознавания → + подтверждение пользователя → поиск книги. Args: update: Telegram update с фото. @@ -155,38 +530,35 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No file = await photo.get_file() image_bytes = bytes(await file.download_as_bytearray()) - isbn = decode_barcode(image_bytes) - if not isbn: - await message.reply_text( - "Не удалось распознать штрихкод на фото.\nПопробуй сфотографировать его крупнее и ровнее.", - ) - return - - await message.reply_text( - f"📦 Найден ISBN: {escape(isbn)}\nИщу книгу...", - parse_mode="HTML", - ) - - try: - book_data = await fetch_book_by_isbn(isbn) - except Exception: - logger.exception("Ошибка при поиске книги по ISBN %s", isbn) - await message.reply_text( - f"Произошла ошибка при поиске книги по ISBN {isbn}.\nПопробуйте позже.", - ) - return + user_data = _require_user_data(context) + pending_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_draft, PendingScanDraft) and pending_draft.awaiting_verso_photo: + parsed_page = parse_title_page_image(image_bytes) + if parsed_page is None or not any((parsed_page.isbn, parsed_page.author, parsed_page.title)): + await message.reply_text( + "Не удалось распознать данные с оборота титульного листа.\n" + "Попробуй сфотографировать страницу целиком, ровно и без сильных теней.", + ) + return - if not book_data: - logger.info("ISBN %s: ни один провайдер не вернул данных", isbn) - user_data = _require_user_data(context) - user_data[PENDING_ISBN_HINT_KEY] = PendingIsbnHint(isbn=isbn) - await message.reply_text( - f"Книга с ISBN {isbn} не найдена.\n" - "Напиши название, автора или часть — попробую с подсказкой (или /cancel).", + merged_draft = _merge_title_page_into_draft( + pending_draft, + isbn=parsed_page.isbn, + author=parsed_page.author, + title=parsed_page.title, ) + user_data[PENDING_SCAN_DRAFT_KEY] = merged_draft + await _reply_with_scan_draft(message, merged_draft) return - await _present_book_for_isbn(update, context, message, book_data, isbn) + isbn = decode_barcode(image_bytes) or "" + draft = PendingScanDraft( + token=_make_scan_draft_token(isbn=isbn, author="", title=""), + isbn=isbn, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = draft + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await _reply_with_scan_draft(message, draft) async def handle_isbn_hint( @@ -226,6 +598,152 @@ async def handle_isbn_hint( await _present_book_for_isbn(update, context, message, book_data, pending.isbn) +async def handle_scan_draft_callback( # noqa: PLR0911 + update: Update, + context: ContextTypes.DEFAULT_TYPE, +) -> None: + """Обрабатывает inline-действия по проверке распознанных полей книги.""" + if not await _access._ensure_access(update): + return + query = _require_callback_query(update) + await query.answer() + user_data = _require_user_data(context) + parsed = _parse_scan_callback_data(query.data or "") + if parsed is None: + await query.edit_message_text("Ошибка: некорректные данные кнопки.") + return + + action, field, token = parsed + draft = _get_pending_scan_draft(user_data, token) + if draft is None: + await query.edit_message_text("Данные распознавания устарели. Отправь фото заново.") + return + + if action == "cancel": + _clear_scan_draft_state(user_data) + await query.edit_message_text("Отменено.") + return + + if action == "edit" and field in {"isbn", "author", "title"}: + await _prompt_scan_draft_field_edit(query, user_data, field) + return + + if action == "verso": + draft.awaiting_verso_photo = True + user_data[PENDING_SCAN_DRAFT_KEY] = draft + await query.edit_message_text(_build_title_page_verso_prompt()) + return + + if isinstance(user_data.get(SCAN_DRAFT_EDITING_KEY), str): + await _answer_edit_in_progress(query) + return + + if action == "confirm": + _clear_scan_draft_state(user_data) + user_data.pop("pending_book", None) + user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + book_data = _build_pending_book_from_scan_draft(draft) + existing = find_existing_book(book_data.title, book_data.author, isbn=book_data.isbn) + if existing is not None: + if existing.id is None: + msg = f"Существующая книга (title={existing.title!r}) не имеет id. Нарушена целостность данных в БД." + raise RuntimeError(msg) + + await query.edit_message_text( + f"Книга уже есть в каталоге:\n\n{format_book(existing)}", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "✏️ Редактировать", + callback_data=f"edit:{existing.id}:menu", + ), + ], + ], + ), + ) + return + + await _add_book_and_show_result(query, book_data) + return + + if action == "lookup": + await _continue_scan_draft(update, context, user_data, query, draft) + return + + await query.edit_message_text("Ошибка: неизвестное действие проверки данных.") + + +async def handle_scan_draft_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку поля в scan draft и снова показывает черновик.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(draft, PendingScanDraft): + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_isbn = cleaned_value if field == "isbn" else draft.isbn + updated_author = cleaned_value if field == "author" else draft.author + updated_title = cleaned_value if field == "title" else draft.title + updated = PendingScanDraft( + token=_make_scan_draft_token(isbn=updated_isbn, author=updated_author, title=updated_title), + isbn=updated_isbn, + author=updated_author, + title=updated_title, + recognized_from_verso=draft.recognized_from_verso, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = updated + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_scan_draft(message, updated) + return True + + +async def handle_pending_book_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку preview книги и снова показывает её перед добавлением.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + pending_book = user_data.get("pending_book") + if not isinstance(pending_book, BookRecord): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_book = _build_pending_book(pending_book, field=field, value=cleaned_value) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_pending_book_preview(message, updated_book, user_data) + return True + + async def _present_book_for_isbn( update: Update, context: ContextTypes.DEFAULT_TYPE, @@ -264,40 +782,17 @@ async def _present_book_for_isbn( return user_data = _require_user_data(context) - pending_book_token = _make_pending_book_token(book_data) - user_data["pending_book"] = book_data - user_data["pending_book_token"] = pending_book_token + user_data.pop(PENDING_BOOK_EDITING_KEY, None) logger.info( "Подготовлена книга к добавлению: title=%r, isbn=%r, for %s", book_data.title, isbn, _update_context_repr(update), ) - - keyboard = InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "✅ Добавить", - callback_data=f"add_book_yes:{pending_book_token}", - ), - InlineKeyboardButton( - "❌ Отмена", - callback_data=f"add_book_no:{pending_book_token}", - ), - ], - ], - ) - preview_book = book_data.model_copy() - await message.reply_text( - f"Найдена книга:\n\n{format_book(preview_book)}\n\nДобавить в каталог?", - parse_mode="HTML", - disable_web_page_preview=True, - reply_markup=keyboard, - ) + await _reply_with_pending_book_preview(message, book_data.model_copy(), user_data) -async def handle_add_book_callback( +async def handle_add_book_callback( # noqa: PLR0911 update: Update, context: ContextTypes.DEFAULT_TYPE, ) -> None: @@ -314,15 +809,45 @@ async def handle_add_book_callback( ) callback_data = query.data or "" - action, _, callback_token = callback_data.partition(":") - pending_book_token = user_data.get("pending_book_token") + parts = callback_data.split(":") + action = parts[0] if parts else "" + field = parts[1] if len(parts) == ADD_BOOK_CALLBACK_WITH_FIELD_PARTS else "" + callback_token = ( + parts[2] if len(parts) == ADD_BOOK_CALLBACK_WITH_FIELD_PARTS else (parts[1] if len(parts) > 1 else "") + ) - if not callback_token or callback_token != pending_book_token: + if not callback_token or _get_pending_book(user_data, callback_token) is None: await query.edit_message_text( "Данные книги устарели. Попробуй отправить фото заново.", ) return + if action == "add_book_edit" and field in {"isbn", "author", "title"}: + await _prompt_pending_book_field_edit(query, user_data, field) + return + + if action in {"add_book_yes", "add_book_lookup"} and isinstance( + user_data.get(PENDING_BOOK_EDITING_KEY), + PendingBookEdit, + ): + await _answer_edit_in_progress(query) + return + + if action == "add_book_lookup": + pending_book = _get_pending_book(user_data, callback_token) + if pending_book is None: + await query.edit_message_text("Данные книги устарели. Попробуй отправить фото заново.") + return + await _lookup_book_by_fields( + update, + context, + user_data, + query, + message=_require_callback_message(query), + manual_book=pending_book, + ) + return + if action not in {"add_book_yes", "add_book_no"}: await query.edit_message_text("Ошибка: некорректные данные кнопки.") return @@ -331,53 +856,19 @@ async def handle_add_book_callback( await query.edit_message_text("Отменено.") user_data.pop("pending_book", None) user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) return book_data_value = user_data.pop("pending_book", None) user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) if not isinstance(book_data_value, BookRecord): await query.edit_message_text( "Данные книги не найдены. Попробуй отправить фото заново.", ) return - try: - book_id = add_book(book_data_value) - except Exception: - logger.exception( - "Ошибка при добавлении книги title=%r в БД", - book_data_value.title, - ) - await query.edit_message_text( - "Не удалось добавить книгу в базу данных. Попробуйте позже.", - ) - return - - book = get_book_by_id(book_id) - if book is None: - msg = ( - f"Книга только что добавлена (id={book_id}), " - f"но не может быть прочитана обратно из БД. " - f"Возможно, проблема с файлом базы данных." - ) - raise RuntimeError(msg) - - keyboard = InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "✏️ Заполнить данные", - callback_data=f"edit:{book_id}:menu", - ), - ], - ], - ) - await query.edit_message_text( - f"✅ Книга добавлена в каталог (#{book_id}):\n\n{format_book(book)}", - parse_mode="HTML", - disable_web_page_preview=True, - reply_markup=keyboard, - ) + await _add_book_and_show_result(query, book_data_value) async def handle_edit_callback( diff --git a/home_library/interfaces/telegram/handlers/commands.py b/home_library/interfaces/telegram/handlers/commands.py index 96dac9a..c6bcf82 100644 --- a/home_library/interfaces/telegram/handlers/commands.py +++ b/home_library/interfaces/telegram/handlers/commands.py @@ -15,8 +15,13 @@ from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers import callbacks as _callbacks from home_library.interfaces.telegram.handlers._helpers import ( + PENDING_BOOK_EDITING_KEY, PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, + PendingBookEdit, PendingIsbnHint, + PendingScanDraft, _build_export_keyboard, _format_telegram_username, _require_message, @@ -186,7 +191,7 @@ async def remove_access_to_library_command(update: Update, _context: ContextType await _access._show_remove_access_command(message) -async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # noqa: PLR0911 """Отменяет активный режим редактирования.""" if not await _access._ensure_access(update): return @@ -195,6 +200,17 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if user_data.pop(PENDING_ISBN_HINT_KEY, None) is not None: await message.reply_text("Ожидание подсказки по ISBN отменено.") return + pending_scan_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_scan_draft, PendingScanDraft): + user_data.pop(PENDING_SCAN_DRAFT_KEY, None) + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await message.reply_text("Проверка распознанных данных отменена.") + return + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + await message.reply_text("Редактирование книги перед добавлением отменено.") + return if user_data.pop("awaiting_access_username", None) is not None: await message.reply_text("Привязка Telegram username отменена.") return @@ -313,25 +329,55 @@ async def _consume_pending_isbn_hint( await _callbacks.handle_isbn_hint(update, context, hint, pending) -async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает текстовый ввод при редактировании поля. +async def _consume_scan_draft_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование полей scan draft.""" + return await _callbacks.handle_scan_draft_field_input(update, context, field, message_text) - Если пользователь не в режиме редактирования — обрабатывает - текст как поисковый запрос. - """ - if not await _access._ensure_access(update): - return + +async def _consume_pending_book_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование preview книги перед добавлением.""" + return await _callbacks.handle_pending_book_field_input(update, context, field, message_text) + + +async def _handle_non_editing_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + message_text: str, +) -> bool: + """Обрабатывает текст вне режима DB-редактирования книги.""" message = _require_message(update) user_data = _require_user_data(context) pending_hint = user_data.get(PENDING_ISBN_HINT_KEY) if isinstance(pending_hint, PendingIsbnHint): - await _consume_pending_isbn_hint(update, context, message.text or "", pending_hint) - return + await _consume_pending_isbn_hint(update, context, message_text, pending_hint) + return True + + scan_draft_editing = user_data.get(SCAN_DRAFT_EDITING_KEY) + if isinstance(scan_draft_editing, str): + handled = await _consume_scan_draft_edit(update, context, scan_draft_editing, message_text) + if handled: + return True + + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + handled = await _consume_pending_book_edit(update, context, pending_book_editing.field, message_text) + if handled: + return True awaiting_access_username = user_data.get("awaiting_access_username") if awaiting_access_username is True: - username = (message.text or "").strip() + username = message_text.strip() admin_user = update.effective_user if admin_user is None: raise RuntimeError("Telegram update не содержит effective_user для настройки доступа.") @@ -342,13 +388,30 @@ async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) except ValueError as exc: await message.reply_text(f"Ошибка: {exc}") - return + return True user_data.pop("awaiting_access_username", None) await message.reply_text( f"Ожидаю первое сообщение от {_format_telegram_username(pending_link.expected_telegram_username)}.", reply_markup=_access._build_access_keyboard(), ) + return True + + return False + + +async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Обрабатывает текстовый ввод при редактировании поля. + + Если пользователь не в режиме редактирования — обрабатывает + текст как поисковый запрос. + """ + if not await _access._ensure_access(update): + return + message = _require_message(update) + user_data = _require_user_data(context) + + if await _handle_non_editing_text(update, context, message.text or ""): return editing_value = user_data.get("editing") diff --git a/home_library/interfaces/telegram/handlers/main.py b/home_library/interfaces/telegram/handlers/main.py index 24c67a9..ea07139 100644 --- a/home_library/interfaces/telegram/handlers/main.py +++ b/home_library/interfaces/telegram/handlers/main.py @@ -108,6 +108,7 @@ def main() -> None: app.add_handler( CallbackQueryHandler(_callbacks.handle_add_book_callback, pattern="^add_book_"), ) + app.add_handler(CallbackQueryHandler(_callbacks.handle_scan_draft_callback, pattern="^scan:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_access_callback, pattern="^access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_remove_access_callback, pattern="^remove_access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_export_callback, pattern="^export:")) diff --git a/home_library/interfaces/telegram/keyboards.py b/home_library/interfaces/telegram/keyboards.py index 45eeb42..c6a53eb 100644 --- a/home_library/interfaces/telegram/keyboards.py +++ b/home_library/interfaces/telegram/keyboards.py @@ -43,3 +43,35 @@ def edit_keyboard(book_id: int) -> InlineKeyboardMarkup: [InlineKeyboardButton("✅ Готово", callback_data=f"edit:{book_id}:done")], ] return InlineKeyboardMarkup(buttons) + + +def scan_draft_keyboard(token: str, *, from_verso: bool = False) -> InlineKeyboardMarkup: + """Строит inline-клавиатуру для проверки распознанных полей книги.""" + verso_label = "📖 Прислать другое фото оборота" if from_verso else "📖 Пришлю оборот титульного листа" + buttons = [ + [ + InlineKeyboardButton("✏️ ISBN", callback_data=f"scan:edit:isbn:{token}"), + InlineKeyboardButton("✏️ Автор", callback_data=f"scan:edit:author:{token}"), + ], + [InlineKeyboardButton("✏️ Название", callback_data=f"scan:edit:title:{token}")], + [InlineKeyboardButton(verso_label, callback_data=f"scan:verso:{token}")], + [InlineKeyboardButton("✅ Всё верно, добавить в библиотеку", callback_data=f"scan:confirm:{token}")], + [InlineKeyboardButton("🔎 Искать информацию", callback_data=f"scan:lookup:{token}")], + [InlineKeyboardButton("❌ Отмена", callback_data=f"scan:cancel:{token}")], + ] + return InlineKeyboardMarkup(buttons) + + +def pending_book_keyboard(token: str) -> InlineKeyboardMarkup: + """Строит inline-клавиатуру для правки preview книги перед добавлением.""" + buttons = [ + [ + InlineKeyboardButton("✏️ ISBN", callback_data=f"add_book_edit:isbn:{token}"), + InlineKeyboardButton("✏️ Автор", callback_data=f"add_book_edit:author:{token}"), + ], + [InlineKeyboardButton("✏️ Название", callback_data=f"add_book_edit:title:{token}")], + [InlineKeyboardButton("🔎 Искать информацию", callback_data=f"add_book_lookup:{token}")], + [InlineKeyboardButton("✅ Добавить", callback_data=f"add_book_yes:{token}")], + [InlineKeyboardButton("❌ Отмена", callback_data=f"add_book_no:{token}")], + ] + return InlineKeyboardMarkup(buttons) diff --git a/home_library/providers/title_page.py b/home_library/providers/title_page.py new file mode 100644 index 0000000..2f7246d --- /dev/null +++ b/home_library/providers/title_page.py @@ -0,0 +1,638 @@ +"""OCR и эвристики для оборота титульного листа книги.""" + +import logging +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError + +from home_library import config + +logger = logging.getLogger(__name__) + +ISBN_PATTERN = re.compile( + r"(?i)\bisbn(?:-1[03])?[:\s]*([0-9xX\-\s]{10,20})", +) +LINE_SPACE_PATTERN = re.compile(r"\s+") +CONTROL_CODE_PATTERN = re.compile(r"^[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*$", re.IGNORECASE) +CONTROL_CODE_PREFIX_PATTERN = re.compile(r"^(?:[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*)\s+", re.IGNORECASE) +TRAILING_FRAGMENT_PENALTY = 3 +TRAILING_FRAGMENT_MAX_LETTERS = 4 +NOISE_PREFIXES = ("ббк", "bbk", "удк", "udk", "©", "copyright", "права", "информация", "интервью", "глава") +NOISE_MIN_LETTERS = 4 +AUTHOR_MIN_WORDS = 2 +AUTHOR_MAX_LENGTH = 60 +OCR_FALLBACK_THRESHOLD = 170 +OCR_BOTTOM_CROPS = (0.5, 0.66) +OCR_TARGET_MAX_EDGE = 2400 +OCR_PSMS = ("3", "4", "6", "11") +OCR_TESSERACT_DPI = "300" +OCR_TESSERACT_OEM = "1" +AUTHOR_MIN_LETTER_RATIO = 0.55 +TITLE_MIN_LETTER_RATIO = 0.45 +SCORING_AUTHOR_MIN_WORDS = 2 +SCORING_AUTHOR_MAX_WORDS = 5 +SCORING_TITLE_MIN_LENGTH = 15 +SCORING_TITLE_MAX_LENGTH = 160 +SENTENCE_TERMINATORS = (".", ")", "!", "?", "”", "»") +JUNK_TOKEN_MAX_LETTERS = 2 +MIN_TERMINATORS_FOR_SEGMENT_STRIP = 2 +TAIL_SPLIT_PARTS = 2 +HEAD_SPLIT_PARTS = 2 +TRAILING_LAST_WORD_MAX_LETTERS = 1 +OCR_SCALE_NOOP_LOWER = 0.95 +OCR_SCALE_NOOP_UPPER = 1.05 +HYPHEN_LINE_END_PATTERN = re.compile(r"(.*\S)[-‐‑‒–—]\s*$") +LOWER_LETTER_START_PATTERN = re.compile(r"^[\sа-яёa-z]") +NON_WORD_PATTERN = re.compile(r"[^\w]", re.UNICODE) +YEAR_PATTERN = re.compile(r"\b(?:19|20)\d{2}\b") +PUBLISHING_MARKERS = ( + "спб", + "спб.", + "м.", + "москва", + "санкт-петербург", + "питер", + "изд", + "издательство", + "эксмо", + "ast", + "act", + "аст", + "альпина", + "манн", +) + + +@dataclass(slots=True) +class ParsedTitlePage: + """Результат OCR-разбора оборота титульного листа.""" + + isbn: str = "" + author: str = "" + title: str = "" + raw_text: str = "" + + +def _normalize_line(line: str) -> str: + """Очищает OCR-строку от лишних пробелов.""" + return LINE_SPACE_PATTERN.sub(" ", line.replace("\u00a0", " ")).strip() + + +def _is_junk_tail_token(token: str) -> bool: + """Определяет, выглядит ли токен как короткий OCR-мусор после конца предложения.""" + stripped = NON_WORD_PATTERN.sub("", token) + if not stripped: + return True + if any(char.isdigit() for char in stripped): + return False + return len(stripped) <= JUNK_TOKEN_MAX_LETTERS + + +def _strip_after_last_terminator(line: str) -> str | None: + """Если после последнего терминатора стоит мусор, обрезает его. Иначе возвращает ``None``.""" + positions = [index for index, char in enumerate(line) if char in SENTENCE_TERMINATORS] + if not positions: + return None + last_pos = positions[-1] + tail = line[last_pos + 1 :].strip() + if tail: + tokens = tail.split() + if tokens and all(_is_junk_tail_token(token) for token in tokens): + return line[: last_pos + 1].rstrip() + return None + if len(positions) >= MIN_TERMINATORS_FOR_SEGMENT_STRIP: + prev_pos = positions[-2] + segment_tokens = line[prev_pos + 1 : last_pos].split() + if segment_tokens and all(_is_junk_tail_token(token) for token in segment_tokens): + return line[: prev_pos + 1].rstrip() + return None + + +def _strip_trailing_short_word(line: str) -> str | None: + """Срезает однобуквенный или не-словесный хвостовой токен. Иначе возвращает ``None``.""" + tail_split = line.rsplit(maxsplit=1) + if len(tail_split) != TAIL_SPLIT_PARTS: + return None + last_word = NON_WORD_PATTERN.sub("", tail_split[1]) + if any(char.isdigit() for char in last_word): + return None + if not last_word or len(last_word) <= TRAILING_LAST_WORD_MAX_LETTERS: + return tail_split[0].rstrip() + return None + + +def _strip_ocr_tail_noise(line: str) -> str: + """Срезает короткий OCR-мусор с хвоста строки. + + Применяет в цикле две стратегии: срез после последнего терминатора и срез + одиночных коротких хвостовых токенов. + """ + line = line.rstrip() + while True: + stripped = _strip_after_last_terminator(line) + if stripped is None: + stripped = _strip_trailing_short_word(line) + if stripped is None: + return line + line = stripped + + +def _strip_ocr_lead_noise(line: str) -> str: + """Срезает однобуквенный OCR-мусор в начале строки.""" + while True: + head_split = line.lstrip().split(maxsplit=1) + if len(head_split) < HEAD_SPLIT_PARTS: + return line.lstrip() + first_word = NON_WORD_PATTERN.sub("", head_split[0]) + if not first_word: + line = head_split[1] + continue + if any(char.isdigit() for char in first_word): + return line.lstrip() + if len(first_word) <= 1: + line = head_split[1] + continue + return line.lstrip() + + +def _join_hyphenated_lines(lines: list[str]) -> list[str]: + """Склеивает строки, где предыдущая оканчивается на дефис, а следующая — на строчную букву.""" + if not lines: + return lines + result: list[str] = [] + skip_next = False + for index, current in enumerate(lines): + if skip_next: + skip_next = False + continue + match = HYPHEN_LINE_END_PATTERN.match(current) + if match and index + 1 < len(lines) and LOWER_LETTER_START_PATTERN.match(lines[index + 1]): + joined = match.group(1) + lines[index + 1].lstrip() + result.append(joined) + skip_next = True + continue + result.append(current) + return result + + +def _strip_control_code_prefix(line: str) -> str: + """Убирает библиотечный шифр из начала строки, если он есть.""" + return CONTROL_CODE_PREFIX_PATTERN.sub("", line).strip() + + +def _contains_letters(value: str) -> bool: + """Проверяет, есть ли в строке буквы.""" + return any(char.isalpha() for char in value) + + +def _letter_ratio(value: str) -> float: + """Возвращает долю букв среди значимых символов строки.""" + significant_chars = [char for char in value if not char.isspace()] + if not significant_chars: + return 0.0 + letters = sum(char.isalpha() for char in significant_chars) + return letters / len(significant_chars) + + +def _contains_cyrillic(value: str) -> bool: + """Проверяет, есть ли в строке кириллица.""" + return any("а" <= char.lower() <= "я" or char.lower() == "ё" for char in value) + + +def _normalize_isbn(candidate: str) -> str: + """Нормализует ISBN-кандидат до строки без дефисов и пробелов.""" + value = re.sub(r"[^0-9Xx]", "", candidate) + if len(value) == config.ISBN13_LENGTH and value.startswith(("978", "979")): + return value + if len(value) == config.ISBN10_LENGTH and (value.isdigit() or (value[:-1].isdigit() and value[-1] in "Xx")): + return value.upper() + return "" + + +def extract_isbn_from_text(text: str) -> str: + """Извлекает первый валидный ISBN из OCR-текста.""" + for match in ISBN_PATTERN.finditer(text): + normalized = _normalize_isbn(match.group(1)) + if normalized: + return normalized + + generic_candidates = re.findall(r"(?:97[89][\-\s]*)?(?:\d[\-\s]*){9,12}[\dXx]", text) + for candidate in generic_candidates: + normalized = _normalize_isbn(candidate) + if normalized: + return normalized + return "" + + +def _looks_like_noise(line: str) -> bool: + """Определяет, относится ли строка к шуму, а не к библиографическим данным.""" + lower = line.lower() + if not lower: + return True + if lower.startswith(NOISE_PREFIXES): + return True + if CONTROL_CODE_PATTERN.match(line): + return True + if lower in {"isbn", "автор", "название"}: + return True + letters = sum(1 for char in line if char.isalpha()) + has_digit = any(char.isdigit() for char in line) + return letters < NOISE_MIN_LETTERS and not has_digit + + +def _strip_publishing_tail(line: str) -> str: + """Отрезает хвост с издательскими данными от строки заглавия.""" + chunks = re.split(r"\s+[—-]\s+", line) + kept: list[str] = [] + for chunk in chunks: + lower = chunk.lower() + if any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(lower): + break + kept.append(chunk) + if kept: + return " — ".join(kept).strip(" .") + return line.strip(" .") + + +def _looks_like_author_line(line: str) -> bool: + """Проверяет, похожа ли строка на имя автора.""" + stripped = _strip_control_code_prefix(line) + return ( + not any(char.isdigit() for char in stripped) and AUTHOR_MIN_WORDS <= len(stripped.split()) <= AUTHOR_MAX_LENGTH + ) + + +def _extract_title_candidate(line: str) -> str: + """Очищает строку заглавия от шифра, автора и издательского хвоста.""" + cleaned = _strip_control_code_prefix(line) + title_part = cleaned.split("/", 1)[0] + return re.sub(r"\s+", " ", _strip_publishing_tail(title_part)).strip(" .") + + +def _looks_like_bibliographic_line(line: str) -> bool: + """Определяет, похожа ли строка на библиографическое описание рядом с ISBN.""" + stripped = _strip_control_code_prefix(line) + lower = stripped.lower() + return "/" in stripped and ( + any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(stripped) is not None + ) + + +def _extract_from_bibliographic_lines(section: list[str]) -> tuple[str, str]: + """Ищет автора и название в библиографической строке рядом с ISBN.""" + for index in range(len(section) - 1, -1, -1): + line = section[index] + if "/" not in line: + continue + + bibliographic_line = next( + ( + candidate + for candidate in _bibliographic_candidates(section, index) + if _looks_like_bibliographic_line(candidate) + ), + None, + ) + if bibliographic_line is None: + continue + + title = _extract_title_candidate(bibliographic_line) + if not title: + continue + + for candidate in reversed(section[max(0, index - 3) : index]): + normalized_candidate = _strip_control_code_prefix(candidate).strip(" .") + if _looks_like_author_line(normalized_candidate): + return normalized_candidate, title + return "", title + return "", "" + + +def _bibliographic_candidates(section: list[str], index: int) -> list[str]: + """Возвращает варианты строк для проверки на библиографическое описание.""" + candidates = [section[index]] + if index + 1 < len(section): + candidates.append(" ".join(section[index : index + 2])) + if index + 2 < len(section): + candidates.append(" ".join(section[index : index + 3])) + return candidates + + +def _collect_meaningful_lines(raw_text: str) -> tuple[list[str], list[str]]: + """Возвращает значимые строки и их подмножество до строки с ISBN.""" + normalized = [_normalize_line(line) for line in raw_text.splitlines()] + non_noise = [line for line in normalized if line and not _looks_like_noise(line)] + cleaned = [_strip_ocr_tail_noise(_strip_ocr_lead_noise(line)) for line in non_noise] + joined = _join_hyphenated_lines([line for line in cleaned if line]) + meaningful_lines = [line for line in joined if not _looks_like_noise(line)] + lines_before_isbn: list[str] = [] + for line in meaningful_lines: + if "isbn" in line.lower(): + break + lines_before_isbn.append(line) + return meaningful_lines, lines_before_isbn + + +def _extract_author_and_title(section: list[str]) -> tuple[str, str]: + """Пытается извлечь автора и название из верхнего блока OCR-строк.""" + bibliographic_author, bibliographic_title = _extract_from_bibliographic_lines(section) + if bibliographic_title: + return bibliographic_author, bibliographic_title + + author = "" + title = "" + + for index, line in enumerate(section): + if not _looks_like_author_line(line): + continue + author = _strip_control_code_prefix(line).strip(" .") + tail_lines = [_strip_control_code_prefix(item) for item in section[index + 1 : index + 4]] + if tail_lines: + title = _extract_title_candidate(" ".join(tail_lines)) + break + + if not title: + title_candidates = [ + _extract_title_candidate(line) for line in section if _strip_control_code_prefix(line).strip(" .") != author + ] + title_candidates = [line for line in title_candidates if line] + if title_candidates: + title = title_candidates[0] + return author, re.sub(r"\s+", " ", title).strip(" .") + + +def _is_low_quality_author(author: str) -> bool: + """Отбрасывает явный OCR-мусор в поле автора.""" + lower = author.lower() + if not author or not _contains_letters(author): + return True + if "_" in author or " or " in lower: + return True + return _letter_ratio(author) < AUTHOR_MIN_LETTER_RATIO + + +def _is_low_quality_title(title: str) -> bool: + """Отбрасывает явный OCR-мусор в поле названия.""" + lower = title.lower() + if not title or not _contains_letters(title): + return True + if "_" in title or any(marker in lower for marker in ("isbn", "удк", "ббк")): + return True + return _letter_ratio(title) < TITLE_MIN_LETTER_RATIO + + +def _sanitize_parsed_title_page(parsed: ParsedTitlePage) -> ParsedTitlePage: + """Очищает распознанные поля от низкокачественного OCR-мусора.""" + author = "" if _is_low_quality_author(parsed.author) else parsed.author + title = "" if _is_low_quality_title(parsed.title) else parsed.title + if author == parsed.author and title == parsed.title: + return parsed + return ParsedTitlePage(isbn=parsed.isbn, author=author, title=title, raw_text=parsed.raw_text) + + +def _trailing_fragment_penalty(text: str) -> int: + """Штраф за хвостовой OCR-фрагмент: короткое последнее слово, начинающееся со строчной буквы.""" + parts = text.rsplit(maxsplit=1) + if len(parts) < TAIL_SPLIT_PARTS: + return 0 + last_word = NON_WORD_PATTERN.sub("", parts[1]) + if not last_word: + return 0 + if any(char.isdigit() for char in last_word): + return 0 + if len(last_word) <= TRAILING_FRAGMENT_MAX_LETTERS and last_word[0].islower(): + return TRAILING_FRAGMENT_PENALTY + return 0 + + +def _score_parsed_title_page(parsed: ParsedTitlePage) -> int: + """Оценивает качество распознанного кандидата для выбора лучшего OCR-прохода.""" + score = 0 + if parsed.isbn: + score += 10 + if parsed.author: + score += 8 + if _contains_cyrillic(parsed.author): + score += 2 + if SCORING_AUTHOR_MIN_WORDS <= len(parsed.author.split()) <= SCORING_AUTHOR_MAX_WORDS: + score += 1 + score -= _trailing_fragment_penalty(parsed.author) + if parsed.title: + score += 8 + if _contains_cyrillic(parsed.title): + score += 2 + if SCORING_TITLE_MIN_LENGTH <= len(parsed.title) <= SCORING_TITLE_MAX_LENGTH: + score += 2 + score -= _trailing_fragment_penalty(parsed.title) + return score + + +def _select_best_parsed_title_page(text_candidates: list[str]) -> ParsedTitlePage | None: + """Выбирает лучший распознанный результат из нескольких OCR-кандидатов.""" + best_parsed: ParsedTitlePage | None = None + best_score = -1 + for candidate in text_candidates: + parsed = parse_title_page_text(candidate) + if parsed is None: + continue + sanitized = _sanitize_parsed_title_page(parsed) + score = _score_parsed_title_page(sanitized) + if score > best_score: + best_parsed = sanitized + best_score = score + return best_parsed + + +def parse_title_page_text(text: str) -> ParsedTitlePage | None: + """Пытается извлечь ISBN, автора и название из OCR-текста.""" + raw_text = text.strip() + if not raw_text: + return None + + isbn = extract_isbn_from_text(raw_text) + meaningful_lines, lines_before_isbn = _collect_meaningful_lines(raw_text) + if not meaningful_lines and not isbn: + return None + + section = lines_before_isbn or meaningful_lines[:6] + author, title = _extract_author_and_title(section) + author = re.sub(r"\s+", " ", author).strip(" .") + + if not any((isbn, author, title)): + return None + return _sanitize_parsed_title_page(ParsedTitlePage(isbn=isbn, author=author, title=title, raw_text=raw_text)) + + +def _open_image(image_bytes: bytes) -> Image.Image | None: + """Открывает изображение для OCR.""" + try: + return Image.open(BytesIO(image_bytes)) + except (OSError, UnidentifiedImageError): + logger.warning("Не удалось открыть изображение для OCR титульного листа (%d байт)", len(image_bytes)) + return None + + +def _image_to_png_bytes(image: Image.Image) -> bytes: + """Сохраняет PIL-изображение в PNG-байты.""" + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _otsu_threshold(image: Image.Image) -> int: + """Считает оптимальный бинарный порог по гистограмме (метод Оцу).""" + histogram = image.histogram()[:256] + total = sum(histogram) + if total == 0: + return OCR_FALLBACK_THRESHOLD + sum_total = sum(intensity * histogram[intensity] for intensity in range(256)) + sum_b = 0.0 + weight_b = 0 + max_variance = -1.0 + threshold = OCR_FALLBACK_THRESHOLD + for intensity in range(256): + weight_b += histogram[intensity] + if weight_b == 0: + continue + weight_f = total - weight_b + if weight_f == 0: + break + sum_b += intensity * histogram[intensity] + mean_b = sum_b / weight_b + mean_f = (sum_total - sum_b) / weight_f + between = weight_b * weight_f * (mean_b - mean_f) ** 2 + if between > max_variance: + max_variance = between + threshold = intensity + return threshold + + +def _scaled_to_target(image: Image.Image) -> Image.Image: + """Приводит изображение к целевому максимальному ребру для OCR.""" + width, height = image.size + longest_edge = max(width, height) + if longest_edge == 0: + return image + scale = OCR_TARGET_MAX_EDGE / longest_edge + if OCR_SCALE_NOOP_LOWER < scale < OCR_SCALE_NOOP_UPPER: + return image + new_size = (max(1, round(width * scale)), max(1, round(height * scale))) + return image.resize(new_size, Image.Resampling.LANCZOS) + + +def _binarize(image: Image.Image, threshold: int) -> Image.Image: + """Бинаризует grayscale-изображение по порогу.""" + return image.point(lambda pixel: 255 if pixel > threshold else 0) + + +def _preprocess_image_variants(image_bytes: bytes) -> list[bytes]: + """Готовит несколько вариантов изображения для OCR.""" + image = _open_image(image_bytes) + if image is None: + return [] + + grayscale = ImageOps.grayscale(image) + contrasted = ImageOps.autocontrast(grayscale, cutoff=1) + base = _scaled_to_target(contrasted) + sharpened = base.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=3)) + denoised = base.filter(ImageFilter.MedianFilter(size=3)) + + otsu_threshold_value = _otsu_threshold(base) + otsu = _binarize(sharpened, otsu_threshold_value) + fallback = _binarize(base, OCR_FALLBACK_THRESHOLD) + denoised_otsu = _binarize(denoised, _otsu_threshold(denoised)) + + variants_with_full_page: list[Image.Image] = [base, sharpened, otsu, fallback, denoised_otsu] + crop_variants: list[Image.Image] = [] + for crop_top_ratio in OCR_BOTTOM_CROPS: + crop_top = int(base.height * crop_top_ratio) + cropped = sharpened.crop((0, crop_top, sharpened.width, sharpened.height)) + crop_variants.append(_binarize(cropped, _otsu_threshold(cropped))) + + variants = [_image_to_png_bytes(image_variant) for image_variant in [*variants_with_full_page, *crop_variants]] + unique_variants: list[bytes] = [] + for variant in variants: + if variant not in unique_variants: + unique_variants.append(variant) + return unique_variants + + +def _run_tesseract(image_bytes: bytes, *, tesseract_path: str, psm: str) -> str | None: + """Запускает Tesseract для одного подготовленного варианта изображения.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_file.write(image_bytes) + temp_path = Path(temp_file.name) + + try: + completed = subprocess.run( # noqa: S603 + [ + tesseract_path, + str(temp_path), + "stdout", + "-l", + "rus+eng", + "--oem", + OCR_TESSERACT_OEM, + "--dpi", + OCR_TESSERACT_DPI, + "--psm", + psm, + ], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return None + finally: + temp_path.unlink(missing_ok=True) + + if completed.returncode != 0: + return None + + text = completed.stdout.strip() + return text or None + + +def _extract_text_candidates_from_title_page(image_bytes: bytes) -> list[str]: + """Запускает OCR в нескольких режимах и возвращает уникальные текстовые кандидаты.""" + prepared_variants = _preprocess_image_variants(image_bytes) + if not prepared_variants: + return [] + + tesseract_path = shutil.which("tesseract") + if tesseract_path is None: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return [] + + candidates: list[str] = [] + for prepared in prepared_variants: + for psm in OCR_PSMS: + text = _run_tesseract(prepared, tesseract_path=tesseract_path, psm=psm) + if text and text not in candidates: + candidates.append(text) + return candidates + + +def extract_text_from_title_page(image_bytes: bytes) -> str | None: + """Запускает системный Tesseract и возвращает OCR-текст страницы.""" + text_candidates = _extract_text_candidates_from_title_page(image_bytes) + if not text_candidates: + return None + + best_parsed = _select_best_parsed_title_page(text_candidates) + if best_parsed is not None: + return best_parsed.raw_text + return text_candidates[0] + + +def parse_title_page_image(image_bytes: bytes) -> ParsedTitlePage | None: + """Выполняет OCR изображения и разбирает распознанный текст.""" + return _select_best_parsed_title_page(_extract_text_candidates_from_title_page(image_bytes)) diff --git a/tests/fixtures/title_page_chelomova.jpg b/tests/fixtures/title_page_chelomova.jpg new file mode 100644 index 0000000..a47f905 Binary files /dev/null and b/tests/fixtures/title_page_chelomova.jpg differ diff --git a/tests/helpers/bot_driver.py b/tests/helpers/bot_driver.py index b996cb3..5377ef2 100644 --- a/tests/helpers/bot_driver.py +++ b/tests/helpers/bot_driver.py @@ -141,6 +141,8 @@ async def click(self, callback_data: str) -> FakeCallbackQuery: if callback_data.startswith("add_book_"): await handlers.handle_add_book_callback(update, self.context) + elif callback_data.startswith("scan:"): + await handlers.handle_scan_draft_callback(update, self.context) elif callback_data.startswith("edit:"): await handlers.handle_edit_callback(update, self.context) elif callback_data.startswith("set:"): diff --git a/tests/test_home_library_e2e.py b/tests/test_home_library_e2e.py index af4fdd6..9732675 100644 --- a/tests/test_home_library_e2e.py +++ b/tests/test_home_library_e2e.py @@ -5,12 +5,12 @@ from home_library import config from home_library.domain.models import BookRecord, EditState from home_library.interfaces.telegram import handlers +from home_library.providers.title_page import ParsedTitlePage from home_library.storage import sqlite as db from home_library.storage import users_sqlite as access_db from tests.conftest import make_book from tests.helpers.bot_driver import BotDriver -PHOTO_FLOW_REPLY_COUNT = 2 OWNER_TELEGRAM_USER_ID = 900 ANNA_TELEGRAM_USER_ID = 901 ANNA_NEW_TELEGRAM_USER_ID = 902 @@ -70,18 +70,61 @@ async def test_text_search_reports_missing_author_in_demo_db(test_db) -> None: @pytest.mark.asyncio async def test_handle_photo_reports_unreadable_barcode(test_db, monkeypatch) -> None: - """Проверяет отрицательную ветку распознавания штрихкода.""" + """Даже без штрихкода бот показывает черновик для ручной проверки.""" monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: None) driver = BotDriver() await driver.send_photo() - assert driver.last_reply_text().startswith("Не удалось распознать штрихкод") + assert "Что удалось распознать:" in driver.last_reply_text() + assert "ISBN: не найден" in driver.last_reply_text() + assert driver.user_data.get("pending_scan_draft") is not None + + +@pytest.mark.asyncio +async def test_scan_draft_keyboard_separates_confirm_lookup_and_cancel_buttons(test_db, monkeypatch) -> None: + """Кнопки подтверждения, поиска и отмены должны быть в отдельных строках.""" + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + driver = BotDriver() + + await driver.send_photo() + + reply_markup = driver.reply()["kwargs"]["reply_markup"] + assert reply_markup.inline_keyboard[3][0].text == "✅ Всё верно, добавить в библиотеку" + assert reply_markup.inline_keyboard[4][0].text == "🔎 Искать информацию" + assert reply_markup.inline_keyboard[5][0].text == "❌ Отмена" + + +@pytest.mark.asyncio +async def test_scan_draft_confirm_adds_book_without_lookup(test_db, monkeypatch) -> None: + """Подтверждение scan draft сразу добавляет книгу и не запускает внешний поиск.""" + calls = 0 + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + nonlocal calls + calls += 1 + return make_book(title="Чистая архитектура", author="Роберт Мартин", isbn="9785446110773") + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + + callback = await driver.click(confirm_callback) + + assert callback.answered is True + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "📦 ISBN: 9785446110773" in callback.edits[0]["text"] + assert calls == 0 + assert driver.user_data.get("pending_book") is None + assert db.find_existing_book("", "", isbn="9785446110773") is not None @pytest.mark.asyncio async def test_handle_photo_finds_existing_book_by_barcode_in_db(test_db, monkeypatch) -> None: - """Проверяет ветку ISBN, который уже есть в каталоге.""" + """Кнопка поиска из черновика находит существующую книгу по ISBN.""" async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: return make_book( @@ -96,17 +139,21 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data + + callback = await driver.click(lookup_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert "📦 Найден ISBN" in driver.reply(0)["text"] - assert "Книга уже есть в каталоге" in driver.reply(1)["text"] - assert "Взрослые дети эмоционально незрелых родителей" in driver.reply(1)["text"] + assert callback.answered is True + assert callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "Книга уже есть в каталоге" in driver.last_reply_text() + assert "Взрослые дети эмоционально незрелых родителей" in driver.last_reply_text() assert "pending_book" not in driver.user_data @pytest.mark.asyncio async def test_handle_photo_adds_new_book_by_barcode_after_confirmation(test_db, monkeypatch) -> None: - """Проверяет полный flow добавления новой книги по ISBN.""" + """Проверяет полный flow: черновик -> поиск по ISBN -> preview -> добавление.""" async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: return make_book( @@ -123,14 +170,17 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() + scan_reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = scan_reply_markup.inline_keyboard[4][0].callback_data + + await driver.click(lookup_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert "Найдена книга" in driver.reply(1)["text"] + assert "Найдена книга" in driver.last_reply_text() pending_book = driver.user_data.get("pending_book") assert isinstance(pending_book, BookRecord) assert pending_book.title == "Чистая архитектура" - reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - confirm_callback = reply_markup.inline_keyboard[0][0].callback_data + reply_markup = driver.reply()["kwargs"]["reply_markup"] + confirm_callback = reply_markup.inline_keyboard[3][0].callback_data callback = await driver.click(confirm_callback) @@ -141,8 +191,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: @pytest.mark.asyncio -async def test_handle_photo_rejects_stale_add_book_confirmation(test_db, monkeypatch) -> None: - """Старая кнопка подтверждения не должна добавлять новую pending-книгу.""" +async def test_handle_photo_rejects_stale_scan_draft_confirmation(test_db, monkeypatch) -> None: + """Старая кнопка поиска из черновика не должна запускать поиск по новым данным.""" books_by_isbn = { "9785446110773": make_book( title="Чистая архитектура", @@ -169,30 +219,31 @@ async def fake_fetch_book_by_isbn(isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() - first_reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - stale_confirm_callback = first_reply_markup.inline_keyboard[0][0].callback_data + first_reply_markup = driver.reply()["kwargs"]["reply_markup"] + stale_lookup_callback = first_reply_markup.inline_keyboard[4][0].callback_data await driver.send_photo() - second_reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - current_confirm_callback = second_reply_markup.inline_keyboard[0][0].callback_data + second_reply_markup = driver.reply()["kwargs"]["reply_markup"] + current_lookup_callback = second_reply_markup.inline_keyboard[4][0].callback_data - stale_callback = await driver.click(stale_confirm_callback) + stale_callback = await driver.click(stale_lookup_callback) assert stale_callback.answered is True - assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново." + assert stale_callback.edits[0]["text"] == "Данные распознавания устарели. Отправь фото заново." assert db.find_existing_book("Чистая архитектура", "Роберт Мартин", isbn="9785446110773") is None assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None - current_callback = await driver.click(current_confirm_callback) + current_callback = await driver.click(current_lookup_callback) assert current_callback.answered is True - assert "✅ Книга добавлена в каталог" in current_callback.edits[0]["text"] - assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is not None + assert current_callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "Найдена книга" in driver.last_reply_text() + assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None @pytest.mark.asyncio async def test_handle_photo_reports_missing_book_outside_db(test_db, monkeypatch) -> None: - """Проверяет отрицательную ветку ISBN вне каталога и вне провайдеров.""" + """По кнопке поиска из черновика бот уходит в fallback с ISBN hint.""" async def fake_fetch_book_by_isbn(_isbn: str) -> None: return None @@ -202,9 +253,12 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data + + await driver.click(lookup_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert driver.reply(1)["text"] == ( + assert driver.last_reply_text() == ( "Книга с ISBN 9789999999999 не найдена.\n" "Напиши название, автора или часть — попробую с подсказкой (или /cancel)." ) @@ -242,6 +296,8 @@ async def fake_fetch_from_ddg_with_context(isbn, hint, _client): driver = BotDriver() await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) assert driver.user_data.get("pending_isbn_hint") is not None await driver.send_text("Мужские правила") @@ -275,6 +331,8 @@ async def fake_fetch_from_ddg_with_context(_isbn, _hint, _client): driver = BotDriver() await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) await driver.send_text("Подсказка") assert driver.last_reply_text() == ( @@ -295,6 +353,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) assert driver.user_data.get("pending_isbn_hint") is not None await driver.cancel() @@ -303,6 +363,321 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: assert driver.last_reply_text() == "Ожидание подсказки по ISBN отменено." +@pytest.mark.asyncio +async def test_scan_draft_accepts_title_page_verso_photo_and_manual_edit(test_db, monkeypatch) -> None: + """Второе фото оборота титульного листа дополняет черновик и допускает ручную правку.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785446118168", + author="Сью Алекс", + title="System Design. Подготовка к сложному интервью", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert "Автор: Сью Алекс" in driver.last_reply_text() + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Сью Алекс" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.send_text("Алекс Сью") + + assert "Автор: Алекс Сью" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].text == "📖 Прислать другое фото оборота" + + +@pytest.mark.asyncio +async def test_scan_draft_accepts_manual_edit_for_isbn_and_title_after_verso(test_db, monkeypatch) -> None: + """После OCR с оборота можно вручную исправить ISBN и название без потери verso-режима.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + edit_isbn_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785042195730" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-04-219573-1") + + assert "ISBN: 9785042195731" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + + edit_title_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "Книготерапия : научно доказанный метод самопомощи" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("Книготерапия") + + assert "Название: Книготерапия" in driver.last_reply_text() + assert "Всё верно?" in driver.last_reply_text() + + +@pytest.mark.asyncio +async def test_scan_draft_confirm_after_verso_edit_adds_book_directly(test_db, monkeypatch) -> None: + """После OCR с оборота и ручной правки автора подтверждение сразу добавляет книгу.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + await driver.send_text("Челомова Надежда Алексеевна") + + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + callback = await driver.click(confirm_callback) + + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "Челомова Надежда Алексеевна" in callback.edits[0]["text"] + assert "Добавить в каталог?" not in callback.edits[0]["text"] + assert driver.user_data.get("pending_book") is None + + +@pytest.mark.asyncio +async def test_scan_draft_blocks_continue_while_field_edit_is_active(test_db, monkeypatch) -> None: + """Во время ручной правки scan draft кнопка поиска не должна срабатывать.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785042195730") + driver = BotDriver() + + await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data + edit_author_callback = reply_markup.inline_keyboard[0][1].callback_data + + await driver.click(edit_author_callback) + callback = await driver.click(lookup_callback) + + assert callback.answer_kwargs == { + "text": "Сначала заверши редактирование поля.", + "show_alert": True, + } + assert callback.edits == [] + + +@pytest.mark.asyncio +async def test_pending_book_preview_supports_editing_before_add(test_db, monkeypatch) -> None: + """Перед добавлением книги можно исправить ISBN, автора и название.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Сью Алекс" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.send_text("Алекс Сью") + assert "✍️ Алекс Сью" in driver.last_reply_text() + updated_preview_markup = driver.reply()["kwargs"]["reply_markup"] + + edit_isbn_callback = updated_preview_markup.inline_keyboard[0][0].callback_data + stale_confirm_callback = preview_markup.inline_keyboard[3][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785446118168" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-4461-1816-0") + assert "📦 ISBN: 9785446118160" in driver.last_reply_text() + + updated_preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_title_callback = updated_preview_markup.inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "System Design. Подготовка к сложному интервью" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("System Design") + assert "📖 System Design" in driver.last_reply_text() + + stale_callback = await driver.click(stale_confirm_callback) + assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново." + + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + callback = await driver.click(confirm_callback) + + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "System Design" in callback.edits[0]["text"] + assert "Алекс Сью" in callback.edits[0]["text"] + assert "9785446118160" in callback.edits[0]["text"] + + +@pytest.mark.asyncio +async def test_pending_book_lookup_preserves_manual_fields(test_db, monkeypatch) -> None: + """Поиск из preview дополняет книгу, но не затирает ручные правки.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_scan_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_scan_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + await driver.send_text("Алекс Сью") + + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + callback = await driver.click(lookup_callback) + + assert callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "📖 System Design. Подготовка к сложному интервью" in driver.last_reply_text() + assert "✍️ Алекс Сью" in driver.last_reply_text() + assert "🏢 Питер" in driver.last_reply_text() + pending_book = driver.user_data.get("pending_book") + assert isinstance(pending_book, BookRecord) + assert pending_book.author == "Алекс Сью" + assert pending_book.publisher == "Питер" + + +@pytest.mark.asyncio +async def test_pending_book_preview_blocks_add_while_field_edit_is_active(test_db, monkeypatch) -> None: + """Во время ручной правки preview книги кнопка добавления не должна срабатывать.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + confirm_callback = preview_markup.inline_keyboard[3][0].callback_data + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + + await driver.click(edit_author_callback) + callback = await driver.click(confirm_callback) + + assert callback.answer_kwargs == { + "text": "Сначала заверши редактирование поля.", + "show_alert": True, + } + assert callback.edits == [] + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_preview_book_edit(test_db, monkeypatch) -> None: + """/cancel сбрасывает режим ручной правки книги перед добавлением.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="Чистая архитектура", + author="Роберт Мартин", + publisher="Питер", + isbn="9785446110773", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Роберт Мартин" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.cancel() + + assert driver.user_data.get("pending_book_editing") is None + assert driver.last_reply_text() == "Редактирование книги перед добавлением отменено." + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_scan_draft(test_db, monkeypatch) -> None: + """/cancel сбрасывает ожидание проверки распознанных данных.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + assert driver.user_data.get("pending_scan_draft") is not None + + await driver.cancel() + + assert driver.user_data.get("pending_scan_draft") is None + assert driver.last_reply_text() == "Проверка распознанных данных отменена." + + @pytest.mark.asyncio async def test_start_users_stats_and_add_library_member_commands(test_db, monkeypatch) -> None: """Проверяет базовые команды бота на реальной временной БД.""" diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 6e408a7..d7d5e87 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -1,9 +1,13 @@ """Tests for provider helpers.""" import builtins as _builtins +import io +import shutil +from pathlib import Path import httpx import pytest +from PIL import Image from home_library.domain.models import BookRecord from home_library.providers import ( @@ -14,9 +18,16 @@ labirint, lookup, piter, + title_page, yandex, ) +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +OTSU_THRESHOLD_MIN = 50 +OTSU_THRESHOLD_MAX = 200 +PREPROCESS_MIN_VARIANTS = 5 + # --------------------------------------------------------------------------- # Barcode # --------------------------------------------------------------------------- @@ -30,6 +41,175 @@ def test_empty_bytes_returns_none(self): assert barcode.decode_barcode(b"") is None +class TestTitlePageParsing: + def test_extract_isbn_from_text(self): + text = "ISBN 978-5-4461-1816-8\nББК 32.973.2-02" + + assert title_page.extract_isbn_from_text(text) == "9785446118168" + + def test_parse_title_page_text_extracts_author_title_and_isbn(self): + text = ( + "C98\n" + "Сью Алекс\n" + "System Design. Подготовка к сложному интервью. — СПб.: Питер, 2022.\n" + "304 с.: ил.\n" + "ISBN 978-5-4461-1816-8\n" + "ББК 32.973.2-02\n" + "УДК 004.41\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785446118168" + assert parsed.author == "Сью Алекс" + assert parsed.title == "System Design. Подготовка к сложному интервью" + + def test_parse_title_page_text_returns_none_for_noise(self): + text = "ББК 32.973.2-02\nУДК 004.41\n© Byte Code LLC" + + assert title_page.parse_title_page_text(text) is None + + def test_parse_title_page_text_strips_control_code_from_title(self): + text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + "Чтение может исцелять, вдохновлять и менять жизнь.\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_parse_title_page_text_prefers_bibliographic_block_near_isbn(self): + text = ( + "УДК 159.9\n" + "ББК 88.52\n" + "П21\n" + "Marianne Power\n" + "HELP ME!\n" + "One woman's quest to find out if self-help\n" + "really can change her life\n" + "Copyright © Marianne Power 2018\n" + "Пауэр, Мэриэнн.\n" + "П21 Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново / Мэриэнн Пауэр; [перевод с английского Я.О. Мышкиной]. - Москва : Эксмо, 2022. - 448 с.\n" + "ISBN 978-5-04-161694-6\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785041616946" + assert parsed.author == "Пауэр, Мэриэнн" + assert ( + parsed.title + == "Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново" + ) + + def test_parse_title_page_text_discards_low_quality_author_and_title(self): + text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "" + assert parsed.title == "" + + def test_parse_title_page_image_prefers_best_ocr_candidate(self, monkeypatch): + noisy_text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + clean_text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + ) + + monkeypatch.setattr( + title_page, + "_extract_text_candidates_from_title_page", + lambda _image_bytes: [noisy_text, clean_text], + ) + + parsed = title_page.parse_title_page_image(b"image-bytes") + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_strip_ocr_tail_noise_drops_short_garbage_after_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. и: ОЕ") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_garbage_between_two_terminators(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. Е.") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_trailing_single_letter_without_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Книготерапия / Надежда Чело- В") + + assert cleaned == "Книготерапия / Надежда Чело-" + + def test_strip_ocr_lead_noise_drops_single_letter_prefix(self): + cleaned = title_page._strip_ocr_lead_noise("j Ч-39 Книготерапия : научно доказанный") + + assert cleaned == "Ч-39 Книготерапия : научно доказанный" + + def test_join_hyphenated_lines_merges_word_split(self): + joined = title_page._join_hyphenated_lines( + ["Надежда Чело-", "мова. — Москва : Эксмо, 2025"], + ) + + assert joined == ["Надежда Челомова. — Москва : Эксмо, 2025"] + + def test_otsu_threshold_returns_value_between_min_and_max(self): + gradient = Image.new("L", (256, 1)) + for x in range(256): + gradient.putpixel((x, 0), x) + + threshold = title_page._otsu_threshold(gradient) + + assert OTSU_THRESHOLD_MIN < threshold < OTSU_THRESHOLD_MAX + + def test_preprocess_image_variants_includes_multiple_unique_variants(self): + image = Image.new("L", (200, 280), color=255) + for x in range(200): + for y in range(140, 280): + image.putpixel((x, y), 0) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + + variants = title_page._preprocess_image_variants(buffer.getvalue()) + + assert len(variants) >= PREPROCESS_MIN_VARIANTS + + @pytest.mark.skipif( + shutil.which("tesseract") is None, + reason="tesseract is not installed in this environment", + ) + def test_parse_title_page_image_handles_chelomova_photo(self): + image_path = FIXTURES_DIR / "title_page_chelomova.jpg" + + parsed = title_page.parse_title_page_image(image_path.read_bytes()) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert "Челомова" in parsed.author + assert "Книготерапия" in parsed.title + + # --------------------------------------------------------------------------- # Google Books # ---------------------------------------------------------------------------