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
# ---------------------------------------------------------------------------