diff --git a/.gitignore b/.gitignore
index 2c70ff3..ee77577 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ users.db
*.log
stdout.txt
stderr.txt
+.bot.pid
.coverage
coverage.xml
htmlcov/
diff --git a/AGENTS.md b/AGENTS.md
index 317fa09..0a2bd5e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,24 @@ export LIBRARY_BOT_TOKEN=""
export LIBRARY_BOT_ADMIN_USERNAME=""
python -m home_library
```
+
+Optional Yandex provider via Playwright (для ISBN, не находящихся в основных каталогах):
+```bash
+python -m pip install -r requirements-dev.txt # включает playwright, playwright-stealth
+playwright install chromium
+export YANDEX_ENABLED=1
+```
+Без `YANDEX_ENABLED` провайдер Yandex мгновенно возвращает `None` и не замедляет гонку.
+
+Каталог РГБ (search.rsl.ru) опрашивается первым в основной гонке и
+включён по умолчанию. Чтобы выключить (например, на нестабильной сети):
+
+```bash
+export RSL_RKP_ENABLED=0
+```
+
+С `RSL_RKP_ENABLED=0` провайдер не делает ни одного сетевого вызова.
+
Notes:
- If `venv/` is absent, create it instead of assuming it exists.
diff --git a/MEMORY.md b/MEMORY.md
index 6154f84..9ebcb20 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -22,7 +22,13 @@
- Бот сам создает `library.db` и `users.db`, если файлов нет.
- `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`.
- `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта.
-- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> DuckDuckGo`.
+- Поиск книги по ISBN идет по цепочке `RSL/RKP (search.rsl.ru) -> Labirint -> Piter -> Google Books -> Yandex (опциональный) -> Playwright site search -> DuckDuckGo`. РГБ опрашивается первым: для префиксов `978-5-…` это даёт ~70% покрытия по реальной выборке без зависимости от Playwright/CAPTCHA.
+- РГБ-провайдер включён по умолчанию; `RSL_RKP_ENABLED=0` мгновенно отключает его без сетевых вызовов. Для запроса нужен один bootstrap-`GET https://search.rsl.ru/` (отдаёт CSRF + cookies), затем `POST /site/ajax-search?language=ru` с `SearchFilterForm[search]=`. Ответ — JSON с `TotalHits` и HTML-фрагментом карточек в `content`. Парсер вытаскивает `data-id` и описание ГОСТ 7.1, превращая его в `BookRecord` (title/author/publisher/topics/link).
+- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `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` сбрасывает ожидание подсказки.
- Бот умеет экспортировать каталог через `/export` в `json`, `xlsx`, `csv`, `yaml` и `db`; без аргумента показывает inline-кнопки выбора формата, но сам экспорт доступен только админу и не включает `users.db`.
- У бота есть access control через `/add_access_to_library`: бот всегда работает только по whitelist.
- Самый первый запуск требует `LIBRARY_BOT_ADMIN_USERNAME=` или `LIBRARY_BOT_ADMIN_TELEGRAM_USER_ID=<числовой id>`; первым в бота должен написать именно этот Telegram-аккаунт, после чего его `telegram_user_id` фиксируется в `users.db` как админский.
@@ -43,6 +49,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 e8480e0..2fffe2e 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,12 @@ Home Library - Telegram-бот для учета домашней библиот
Обычный сценарий работы с ботом выглядит так:
- Вы фотографируете штрихкод на задней стороне книги.
-- Бот распознает ISBN по фотографии.
-- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки.
-- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию.
-- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books и DuckDuckGo.
+- Бот распознает ISBN по фотографии и сначала показывает черновик распознанных полей: `ISBN`, `Автор`, `Название`.
+- Пользователь может исправить любое поле вручную перед поиском.
+- Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год.
+- Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки.
+- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: РГБ (search.rsl.ru — национальный депозитарий, основной для префикса `978-5-…`), Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo.
+- Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`.
- Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки.
- После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше).
@@ -21,6 +23,7 @@ Home Library - Telegram-бот для учета домашней библиот
- отправьте `/start`, чтобы увидеть краткую справку
- отправьте `/search <запрос>` или просто текстовое сообщение, чтобы найти книгу в каталоге
- отправьте фото штрихкода, если хотите найти и добавить книгу по ISBN
+- при необходимости пришлите фото оборота титульного листа, чтобы бот добрал автора, название и ISBN по выходным сведениям
- используйте кнопки в боте, чтобы менять статус, оценку, комментарий, темы и местоположение
- если вы админ, используйте `/add_access_to_library` для выдачи Telegram-доступа членам семьи
- если вы админ, используйте `/export ` для выгрузки каталога
@@ -42,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
@@ -59,6 +72,69 @@ python -m home_library
Если `library.db` еще нет, приложение само создаст ее из `example_library.xlsx`.
+## Опционально: Яндекс через Playwright
+
+Ряд ISBN (например, `9785961449136`) не находится ни в одном книжном API/HTML —
+только в поиске Яндекса. Обычный HTTP к `ya.ru` ловит SmartCaptcha, поэтому
+опциональный провайдер ходит через headless Chromium с `playwright-stealth`.
+
+1. Установите dev-зависимости:
+ ```bash
+ python -m pip install -r requirements-dev.txt
+ ```
+2. Скачайте Chromium (≈170MB):
+ ```bash
+ playwright install chromium
+ ```
+3. Включите провайдер флагом:
+ ```bash
+ export YANDEX_ENABLED=1
+ ```
+
+Без флага провайдер тихо возвращает `None` и бот работает как раньше. Первый
+запрос в сессии ≈3–5 с (старт Chromium). Если Яндекс всё же показал капчу —
+сработает интерактивная подсказка, бот попросит название/автора и перезапустит
+поиск через DuckDuckGo.
+
+### Каталог РГБ (search.rsl.ru)
+
+Российская государственная библиотека (включая бывшую Российскую книжную
+палату) — национальный депозитарий и хранит данные по большинству книг,
+вышедших с префиксом `978-5-…`. Этот провайдер опрашивается **первым** в
+основной гонке и обеспечивает наиболее полное покрытие ру-сегмента.
+
+Включён по умолчанию, не требует отдельных зависимостей. Чтобы выключить —
+например, на нестабильной сети:
+
+```bash
+export RSL_RKP_ENABLED=0
+```
+
+С `RSL_RKP_ENABLED=0` провайдер мгновенно возвращает `None` и не делает
+ни одного сетевого запроса.
+
+## Фото Оборота Титульного Листа
+
+Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`.
+
+Подходящее фото обычно содержит выходные сведения, например:
+
+```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).
@@ -166,7 +242,7 @@ python -m home_library
## Важно
- `/search` и обычное текстовое сообщение ищут только по уже сохраненным книгам в каталоге домашней библиотеки
-- внешний поиск используется только в сценарии добавления книги по фото штрихкода
+- внешний поиск используется только в сценарии добавления книги по фото штрихкода или после подтверждения OCR-черновика
- экспорт `/export db` отдает только `library.db`; `users.db` в экспорт не входит
## Файлы Данных
diff --git a/home_library/config.py b/home_library/config.py
index 75997d1..d871214 100644
--- a/home_library/config.py
+++ b/home_library/config.py
@@ -22,6 +22,11 @@
GOOGLE_BOOKS_URL = "https://www.googleapis.com/books/v1/volumes"
+RSL_RKP_BASE_URL = "https://search.rsl.ru"
+RSL_RKP_BOOTSTRAP_PATH = "/"
+RSL_RKP_AJAX_SEARCH_PATH = "/site/ajax-search?language=ru"
+RSL_RKP_RECORD_URL_TEMPLATE = "https://search.rsl.ru/ru/record/{record_id}"
+RSL_RKP_ENABLED_ENV = "RSL_RKP_ENABLED"
LABIRINT_SEARCH_URL = "https://www.labirint.ru/search/{isbn}/"
LABIRINT_HEADERS = {"User-Agent": "Mozilla/5.0"}
PITER_SEARCH_URL = "https://www.piter.com/search.json"
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 289fc6b..14c2cff 100644
--- a/home_library/interfaces/telegram/handlers/_helpers.py
+++ b/home_library/interfaces/telegram/handlers/_helpers.py
@@ -1,6 +1,8 @@
"""Общие константы, протоколы и вспомогательные функции Telegram-хендлеров."""
import logging
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
from io import BytesIO
from typing import Any, Protocol, cast
@@ -19,6 +21,41 @@
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)
+class PendingIsbnHint:
+ """Состояние ожидания подсказки от пользователя по не найденному ISBN."""
+
+ isbn: str
+ 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 2ece5a5..955964b 100644
--- a/home_library/interfaces/telegram/handlers/callbacks.py
+++ b/home_library/interfaces/telegram/handlers/callbacks.py
@@ -2,20 +2,33 @@
import hashlib
import logging
+import re
+from typing import cast
+import httpx
from telegram import (
+ CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
+ Message,
Update,
)
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,
@@ -23,18 +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:
@@ -43,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,
@@ -126,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 с фото.
@@ -150,34 +530,228 @@ 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Попробуй сфотографировать его крупнее и ровнее.",
+ 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
+
+ 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 message.reply_text(
- f"📦 Найден ISBN: {escape(isbn)}\nИщу книгу...",
- parse_mode="HTML",
+ 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(
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ hint: str,
+ pending: PendingIsbnHint,
+) -> None:
+ """Дообходит подсказку от пользователя: ищет книгу по ISBN + hint.
+
+ Используется как safety-net когда первичный поиск по ISBN не нашёл книгу.
+ """
+ message = _require_message(update)
+ logger.info(
+ "Handling ISBN hint isbn=%s hint=%r for %s",
+ pending.isbn,
+ hint,
+ _update_context_repr(update),
)
try:
- book_data = await fetch_book_by_isbn(isbn)
+ async with httpx.AsyncClient(timeout=10) as client:
+ book_data = await fetch_from_ddg_with_context(pending.isbn, hint, client)
except Exception:
- logger.exception("Ошибка при поиске книги по ISBN %s", isbn)
+ logger.exception("Ошибка при поиске по подсказке для ISBN %s", pending.isbn)
await message.reply_text(
- f"Произошла ошибка при поиске книги по ISBN {isbn}.\nПопробуйте позже.",
+ "Произошла ошибка при поиске по подсказке. Попробуй ещё раз.",
)
return
if not book_data:
- logger.info("ISBN %s: ни один провайдер не вернул данных", isbn)
await message.reply_text(
- f"Книга с ISBN {isbn} не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск.",
+ "По подсказке тоже ничего не нашлось. Попробуй другой ISBN или добавь книгу вручную.",
)
return
+ 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,
+ message: Message,
+ book_data: BookRecord,
+ isbn: str,
+) -> None:
+ """Показывает найденную книгу: либо карточку существующей, либо preview для добавления."""
existing = find_existing_book(book_data.title, book_data.author, isbn=isbn)
if existing:
if existing.id is None:
@@ -208,40 +782,17 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
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),
)
+ await _reply_with_pending_book_preview(message, book_data.model_copy(), user_data)
- 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,
- )
-
-async def handle_add_book_callback(
+async def handle_add_book_callback( # noqa: PLR0911
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
@@ -258,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
@@ -275,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 52c8ff9..c6bcf82 100644
--- a/home_library/interfaces/telegram/handlers/commands.py
+++ b/home_library/interfaces/telegram/handlers/commands.py
@@ -13,7 +13,15 @@
from home_library.domain.models import EditState
from home_library.interfaces.telegram.formatters import format_book, get_field_label
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,
@@ -183,12 +191,26 @@ 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
message = _require_message(update)
user_data = _require_user_data(context)
+ 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
@@ -290,19 +312,72 @@ async def text_search(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> No
await do_search(update, query)
-async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Обрабатывает текстовый ввод при редактировании поля.
-
- Если пользователь не в режиме редактирования — обрабатывает
- текст как поисковый запрос.
- """
- if not await _access._ensure_access(update):
+async def _consume_pending_isbn_hint(
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ message_text: str,
+ pending: PendingIsbnHint,
+) -> None:
+ """Обрабатывает подсказку по ISBN: валидирует ввод и делегирует в callbacks."""
+ message = _require_message(update)
+ user_data = _require_user_data(context)
+ user_data.pop(PENDING_ISBN_HINT_KEY, None)
+ hint = message_text.strip()
+ if not hint:
+ await message.reply_text("Пустая подсказка. Попробуй ещё раз или /cancel.")
return
+ await _callbacks.handle_isbn_hint(update, context, hint, pending)
+
+
+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)
+
+
+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, 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 для настройки доступа.")
@@ -313,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/_serp_parser.py b/home_library/providers/_serp_parser.py
new file mode 100644
index 0000000..4f09f19
--- /dev/null
+++ b/home_library/providers/_serp_parser.py
@@ -0,0 +1,274 @@
+"""Парсер поисковой выдачи (SERP).
+
+Общая логика для извлечения названия и автора книги из заголовков и
+сниппетов поисковых движков. Используется провайдерами DuckDuckGo и
+Yandex: они отличаются только способом получения ``titles``/``snippets``,
+а очистка и выбор лучшего кандидата одинаковые.
+"""
+
+import html
+import re
+
+from home_library import config
+from home_library.domain.models import BookRecord
+
+MIN_MEANINGFUL_TITLE_LENGTH = 3
+AUTHOR_LABEL_WORD_COUNT = 2
+AUTHOR_NAME_PART = r"[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*"
+
+ALPINA_SERIES_PREFIX_PATTERN = re.compile(r"^АНФ\.", re.IGNORECASE)
+MARKETPLACE_BOOK_PREFIX_PATTERN = re.compile(
+ r"^Нехудожественная книга\s+[А-ЯЁA-Za-z-]+\s+",
+ re.IGNORECASE,
+)
+AGE_MARK_PATTERN = re.compile(r"\s*\(\d+\+\)")
+ARTICLE_SUFFIX_PATTERN = re.compile(r"\s+арт\.\s*\d{10,13}\s*$", re.IGNORECASE)
+EDITION_SUFFIX_PATTERN = re.compile(r"\.\s*\d+-е изд\..*$", re.IGNORECASE)
+PUBLISHER_SUFFIX_PATTERN = re.compile(
+ r"\s+Альпина(?:\s*\.{2,}|\.|$).*",
+ re.IGNORECASE,
+)
+AUTHOR_LABEL_PATTERN = re.compile(
+ rf"\bавтор\s+(?P{AUTHOR_NAME_PART}(?:\s+{AUTHOR_NAME_PART}){{1,2}}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)",
+ re.IGNORECASE,
+)
+AMAZON_AUTHOR_PATTERN = re.compile(
+ r":\s*97[89]\d{10}\s*:\s*(?P[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){1,2})\s*:\s*Books",
+)
+TITLE_NOISE_PATTERN = re.compile(r"/|\bизд\b", re.IGNORECASE)
+AUTHOR_TITLE_PATTERN = re.compile(
+ rf"^(?P{AUTHOR_NAME_PART}(?:\s+{AUTHOR_NAME_PART}){{1,2}}):\s+(?P.+)$",
+)
+
+
+def _clean_title(raw_title: str) -> str:
+ """Очищает заголовок результата поиска от шумовых суффиксов.
+
+ Убирает названия магазинов (Ozon, Amazon, Wildberries, ...),
+ доменные префиксы, marketplace-префиксы, возрастные метки,
+ артикульные и edition-суффиксы, а также завершающие многоточия.
+
+ Args:
+ raw_title: Сырой заголовок из поисковой выдачи.
+
+ Returns:
+ Очищенный заголовок.
+
+ Example::
+
+ >>> _clean_title("Война и мир - Ozon")
+ 'Война и мир'
+ >>> _clean_title("ozon.ru: Война и мир...")
+ 'Война и мир'
+ """
+ cleaned_title = raw_title
+
+ # Отсекаем всё после разделителей магазинов и маркетплейсов
+ for separator in (
+ " | ",
+ " - Ozon",
+ " - Amazon",
+ " - Wildberries",
+ " - Читай-город",
+ " - Лабиринт",
+ " - купить в",
+ " купить",
+ " Купить",
+ ):
+ cleaned_title = cleaned_title.split(separator)[0]
+
+ cleaned_title = cleaned_title.strip(config.STRIP_CHARS)
+ cleaned_title = config.TITLE_PREFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = ALPINA_SERIES_PREFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = MARKETPLACE_BOOK_PREFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = AGE_MARK_PATTERN.sub("", cleaned_title)
+ cleaned_title = ARTICLE_SUFFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = EDITION_SUFFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = PUBLISHER_SUFFIX_PATTERN.sub("", cleaned_title)
+ cleaned_title = config.TITLE_SUFFIX_PATTERN.sub("", cleaned_title)
+ return cleaned_title.strip(config.STRIP_CHARS)
+
+
+def _normalize_author(author: str, *, from_author_label: bool = False) -> str:
+ """Нормализует автора, извлечённого из поисковой выдачи."""
+ normalized_author = html.unescape(author).strip(config.STRIP_CHARS + ":;,")
+ if not from_author_label:
+ return normalized_author
+
+ parts = normalized_author.split()
+ if len(parts) != AUTHOR_LABEL_WORD_COUNT:
+ return normalized_author
+ if not all(config.CYRILLIC_PATTERN.search(part) for part in parts):
+ return normalized_author
+ if any("." in part for part in parts):
+ return normalized_author
+ if "-" in parts[1] or len(parts[1]) > len(parts[0]) + AUTHOR_LABEL_WORD_COUNT:
+ return normalized_author
+
+ # Сниппеты каталогов часто отдают автора в формате «Фамилия Имя» — переворачиваем.
+ return f"{parts[1]} {parts[0]}"
+
+
+def _extract_author_from_snippets(snippets: list[str]) -> str:
+ """Пытается извлечь автора из сниппетов результатов поиска."""
+ for raw_snippet in snippets:
+ snippet = html.unescape(raw_snippet)
+ author_match = AUTHOR_LABEL_PATTERN.search(snippet)
+ if author_match:
+ return _normalize_author(
+ author_match.group("author"),
+ from_author_label=True,
+ )
+
+ for raw_snippet in snippets:
+ snippet = html.unescape(raw_snippet)
+ author_match = AMAZON_AUTHOR_PATTERN.search(snippet)
+ if author_match:
+ return _normalize_author(author_match.group("author"))
+
+ return ""
+
+
+def _title_noise_score(title: str) -> int:
+ """Возвращает грубую оценку шумности заголовка из поисковой выдачи."""
+ return len(TITLE_NOISE_PATTERN.findall(title))
+
+
+def _split_author_and_title(cleaned_title: str) -> tuple[str, str] | None:
+ """Пытается разобрать заголовок вида ``Автор: Название``."""
+ author_match = AUTHOR_TITLE_PATTERN.match(cleaned_title)
+ if not author_match:
+ return None
+
+ author = _normalize_author(
+ author_match.group("author"),
+ from_author_label=True,
+ )
+ title = author_match.group("title").strip(config.STRIP_CHARS)
+ if len(title) <= MIN_MEANINGFUL_TITLE_LENGTH:
+ return None
+ return author, title
+
+
+def _looks_truncated(raw_title: str) -> bool:
+ """Определяет, что поисковик обрезал заголовок многоточием."""
+ return "..." in raw_title or "…" in raw_title
+
+
+def _merge_truncated_titles(cleaned_titles: list[str], truncated_titles: set[str]) -> list[str]:
+ """Подменяет усечённые заголовки их более полными вариантами."""
+ merged: list[str] = []
+ for title in cleaned_titles:
+ replacement = title
+ if title in truncated_titles:
+ for candidate in cleaned_titles:
+ if candidate == title:
+ continue
+ if candidate.lower().startswith(title.lower()) and len(candidate) > len(replacement):
+ replacement = candidate
+ merged.append(replacement)
+ return merged
+
+
+def extract_book_from_serp(
+ titles: list[str],
+ isbn: str,
+ snippets: list[str] | None = None,
+) -> BookRecord | None:
+ """Пытается извлечь книгу из набора заголовков поисковой выдачи.
+
+ Алгоритм:
+ 1. Отбираем заголовки с кириллицей (русскоязычные книги).
+ 2. Очищаем каждый от шума через ``_clean_title``.
+ 3. Разбиваем заголовки с «—» на пару (автор, название).
+ 4. Выбираем наиболее частый очищенный заголовок как название.
+ 5. Автора берём из пар «автор — название» или из сниппетов.
+
+ Args:
+ titles: Список заголовков результатов поиска.
+ isbn: ISBN для записи в результат.
+ snippets: Опциональные сниппеты, из которых пробуем достать автора.
+
+ Returns:
+ ``BookRecord`` с извлечёнными данными или ``None``,
+ если не удалось найти подходящий заголовок.
+
+ Example::
+
+ >>> titles = [
+ ... "Толстой — Война и мир",
+ ... "Война и мир | Лабиринт",
+ ... "Война и мир купить",
+ ... ]
+ >>> book = extract_book_from_serp(titles, "9785171234567")
+ >>> book.title
+ 'Война и мир'
+ >>> book.author
+ 'Толстой'
+ """
+ russian_titles = [title for title in titles if config.CYRILLIC_PATTERN.search(title)]
+ if not russian_titles:
+ return None
+
+ cleaned: list[str] = []
+ truncated_cleaned: set[str] = set()
+ em_dash_parts: list[tuple[str, str]] = []
+ author_title_parts: list[tuple[str, str]] = []
+ for raw_title in russian_titles:
+ cleaned_title = _clean_title(raw_title)
+ if len(cleaned_title) <= MIN_MEANINGFUL_TITLE_LENGTH:
+ continue
+ if " — " in cleaned_title:
+ left, right = cleaned_title.split(" — ", 1)
+ em_dash_parts.append((left.strip(), right.strip()))
+ cleaned.append(right.strip())
+ elif author_title_part := _split_author_and_title(cleaned_title):
+ author, title = author_title_part
+ author_title_parts.append((author, title))
+ cleaned.append(title)
+ else:
+ cleaned.append(cleaned_title)
+
+ if _looks_truncated(raw_title):
+ truncated_cleaned.add(cleaned[-1])
+
+ if not cleaned:
+ return None
+
+ cleaned = _merge_truncated_titles(cleaned, truncated_cleaned)
+
+ lowered = [title.lower() for title in cleaned]
+ best = max(
+ cleaned,
+ key=lambda candidate: (
+ lowered.count(candidate.lower()),
+ -_title_noise_score(candidate),
+ len(candidate),
+ ),
+ )
+
+ author = ""
+ for left, right in em_dash_parts:
+ if right.lower() == best.lower():
+ author = left
+ break
+ if not author and em_dash_parts:
+ author = em_dash_parts[0][0]
+ if not author:
+ for candidate_author, candidate_title in author_title_parts:
+ if best.lower() == candidate_title.lower() or best.lower().startswith(candidate_title.lower()):
+ author = candidate_author
+ break
+ if not author and author_title_parts:
+ author = author_title_parts[0][0]
+ if not author and snippets:
+ author = _extract_author_from_snippets(snippets)
+
+ return BookRecord(
+ title=best,
+ author=author,
+ publisher="",
+ topics="",
+ link="",
+ isbn=isbn,
+ )
diff --git a/home_library/providers/ddg.py b/home_library/providers/ddg.py
index c0e3aa0..7417b3a 100644
--- a/home_library/providers/ddg.py
+++ b/home_library/providers/ddg.py
@@ -1,9 +1,11 @@
"""DuckDuckGo provider adapter.
Ищет книгу по ISBN через HTML-версию DuckDuckGo, парсит результаты
-и пытается извлечь название и автора из заголовков.
+и пытается извлечь название и автора из заголовков. Логика разбора
+вынесена в ``_serp_parser`` и переиспользуется с Yandex-провайдером.
"""
+import html
import logging
import re
from http import HTTPStatus
@@ -12,141 +14,76 @@
from home_library import config
from home_library.domain.models import BookRecord
-
-MIN_MEANINGFUL_TITLE_LENGTH = 3
+from home_library.providers._serp_parser import extract_book_from_serp
logger = logging.getLogger(__name__)
+DDG_SEARCH_URL = "https://html.duckduckgo.com/html/"
+DDG_USER_AGENT = "Mozilla/5.0"
+
+RESULT_TITLE_PATTERN = re.compile(
+ r'class="result__a"[^>]*>(.*?)]*>(.*?)(?:a|div)>',
+ re.DOTALL,
+)
+
+
+def _parse_serp_response(response_text: str, isbn: str) -> BookRecord | None:
+ """Разбирает HTML DuckDuckGo и возвращает ``BookRecord``."""
+ titles_html = RESULT_TITLE_PATTERN.findall(response_text)
+ snippets_html = RESULT_SNIPPET_PATTERN.findall(response_text)
+ titles = [
+ html.unescape(config.TITLE_HTML_PATTERN.sub("", title)).strip() for title in titles_html[: config.SEARCH_LIMIT]
+ ]
+ snippets = [
+ html.unescape(config.TITLE_HTML_PATTERN.sub("", snippet)).strip()
+ for snippet in snippets_html[: config.SEARCH_LIMIT]
+ ]
+ return extract_book_from_serp(titles, isbn, snippets=snippets)
+
+
+async def _search_ddg(query: str, isbn: str, client: httpx.AsyncClient) -> BookRecord | None:
+ """Выполняет GET-запрос к DuckDuckGo и разбирает ответ."""
+ try:
+ resp = await client.get(
+ DDG_SEARCH_URL,
+ params={"q": query},
+ headers={"User-Agent": DDG_USER_AGENT},
+ follow_redirects=True,
+ )
+ if resp.status_code != HTTPStatus.OK:
+ logger.warning(
+ "DuckDuckGo вернул HTTP %s для запроса %r",
+ resp.status_code,
+ query,
+ )
+ return None
+ return _parse_serp_response(resp.text, isbn)
-def _clean_ddg_title(raw_title: str) -> str:
- """Очищает заголовок результата DuckDuckGo от шумовых суффиксов.
-
- Убирает названия магазинов (Ozon, Amazon, Wildberries, ...),
- доменные префиксы и завершающие многоточия.
-
- Args:
- raw_title: Сырой заголовок из HTML-страницы DuckDuckGo.
-
- Returns:
- Очищенный заголовок.
-
- Example::
-
- >>> _clean_ddg_title("Война и мир - Ozon")
- 'Война и мир'
- >>> _clean_ddg_title("ozon.ru: Война и мир...")
- 'Война и мир'
- """
- cleaned_title = raw_title
-
- # Отсекаем всё после разделителей магазинов
- for separator in (
- " | ",
- " - Ozon",
- " - Amazon",
- " - Wildberries",
- " - Читай-город",
- " - Лабиринт",
- " купить",
- " Купить",
- ):
- cleaned_title = cleaned_title.split(separator)[0]
-
- # Убираем кавычки, многоточия и т.п. по краям
- cleaned_title = cleaned_title.strip(config.STRIP_CHARS)
- # Убираем доменный префикс вроде "ozon.ru: "
- cleaned_title = config.TITLE_PREFIX_PATTERN.sub("", cleaned_title)
- # Убираем завершающие многоточия "..."
- return config.TITLE_SUFFIX_PATTERN.sub("", cleaned_title)
-
-
-def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None:
- """Пытается извлечь книгу из набора заголовков результатов DuckDuckGo.
-
- Алгоритм:
- 1. Отбираем заголовки с кириллицей (русскоязычные книги).
- 2. Очищаем каждый от шума через ``_clean_ddg_title``.
- 3. Разбиваем заголовки с «—» на пару (автор, название).
- 4. Выбираем наиболее частый очищенный заголовок как название.
- 5. Автора берём из пар «автор — название», если есть.
-
- Args:
- titles: Список заголовков результатов поиска.
- isbn: ISBN для записи в результат.
-
- Returns:
- ``BookRecord`` с извлечёнными данными или ``None``,
- если не удалось найти подходящий заголовок.
-
- Example::
-
- >>> titles = [
- ... "Толстой — Война и мир",
- ... "Война и мир | Лабиринт",
- ... "Война и мир купить",
- ... ]
- >>> book = _extract_ddg_book(titles, "9785171234567")
- >>> book.title
- 'Война и мир'
- >>> book.author
- 'Толстой'
- """
- # Шаг 1: оставляем только кириллические заголовки
- russian_titles = [title for title in titles if config.CYRILLIC_PATTERN.search(title)]
- if not russian_titles:
+ except httpx.HTTPError as exc:
+ logger.warning(
+ "Сетевая ошибка при поиске через DuckDuckGo (%r): %s — %s",
+ query,
+ type(exc).__name__,
+ exc,
+ )
return None
-
- cleaned: list[str] = []
- em_dash_parts: list[tuple[str, str]] = [] # (автор, название)
- for raw_title in russian_titles:
- cleaned_title = _clean_ddg_title(raw_title)
- # Слишком короткие — скорее всего мусор
- if len(cleaned_title) <= MIN_MEANINGFUL_TITLE_LENGTH:
- continue
- # Формат «Автор — Название» встречается на Ozon, Лабиринте и т.д.
- if " — " in cleaned_title:
- left, right = cleaned_title.split(" — ", 1)
- em_dash_parts.append((left.strip(), right.strip()))
- cleaned.append(right.strip())
- else:
- cleaned.append(cleaned_title)
-
- if not cleaned:
+ except (KeyError, TypeError, ValueError) as exc:
+ logger.warning(
+ "Ошибка парсинга результатов DuckDuckGo (%r): %s — %s",
+ query,
+ type(exc).__name__,
+ exc,
+ )
return None
- # Шаг 3: выбираем наиболее частый заголовок как лучший кандидат
- lowered = [title.lower() for title in cleaned]
- best = max(
- cleaned,
- key=lambda candidate: (lowered.count(candidate.lower()), len(candidate)),
- )
-
- # Шаг 4: ищем автора из пар «автор — название»
- author = ""
- for left, right in em_dash_parts:
- if right.lower() == best.lower():
- author = left
- break
- # Если точного совпадения нет — берём первого автора из пар
- if not author and em_dash_parts:
- author = em_dash_parts[0][0]
-
- return BookRecord(
- title=best,
- author=author,
- publisher="",
- topics="",
- link="",
- isbn=isbn,
- )
-
async def fetch_from_ddg(isbn: str, client: httpx.AsyncClient) -> BookRecord | None:
"""Ищет книгу по ISBN через HTML-версию DuckDuckGo.
- Использует ``html.duckduckgo.com`` (без JS), парсит заголовки
- результатов регулярным выражением и передаёт в ``_extract_ddg_book``.
-
Args:
isbn: ISBN-код для поиска.
client: Async HTTP-клиент (httpx).
@@ -161,44 +98,29 @@ async def fetch_from_ddg(isbn: str, client: httpx.AsyncClient) -> BookRecord | N
... if book:
... print(book.title)
"""
- try:
- resp = await client.get(
- "https://html.duckduckgo.com/html/",
- params={"q": isbn},
- headers={"User-Agent": "Mozilla/5.0"},
- follow_redirects=True,
- )
- if resp.status_code != HTTPStatus.OK:
- logger.warning(
- "DuckDuckGo вернул HTTP %s для ISBN %s",
- resp.status_code,
- isbn,
- )
- return None
+ return await _search_ddg(isbn, isbn, client)
- # Извлекаем текст заголовков из HTML результатов
- titles_html = re.findall(
- r'class="result__a"[^>]*>(.*?) BookRecord | None:
+ """Ищет книгу по ISBN + подсказке пользователя через DuckDuckGo.
+
+ Используется как safety-net, когда поиск по одному ISBN не нашёл книгу,
+ а пользователь подсказал автора или часть названия.
+
+ Args:
+ isbn: ISBN-код для поиска.
+ hint: Подсказка от пользователя (часть названия, автор и т.д.).
+ client: Async HTTP-клиент (httpx).
+
+ Returns:
+ ``BookRecord`` или ``None``, если поиск не дал результатов.
+ """
+ normalized_hint = hint.strip()
+ if not normalized_hint:
+ return await fetch_from_ddg(isbn, client)
+ query = f"{isbn} {normalized_hint}"
+ return await _search_ddg(query, isbn, client)
diff --git a/home_library/providers/lookup.py b/home_library/providers/lookup.py
index f9dbbec..8e5c128 100644
--- a/home_library/providers/lookup.py
+++ b/home_library/providers/lookup.py
@@ -1,7 +1,8 @@
"""Provider orchestration for Home Library.
Координирует поиск по ISBN через несколько провайдеров с fallback-логикой:
-Labirint → Piter → Google Books (параллельно), затем DuckDuckGo.
+Labirint → Piter → Google Books → Yandex (опционально) параллельно,
+затем Playwright-поиск по книжным сайтам и DuckDuckGo.
"""
import asyncio
@@ -14,6 +15,11 @@
from home_library.providers.google_books import fetch_from_google_books
from home_library.providers.labirint import fetch_from_labirint
from home_library.providers.piter import fetch_from_piter
+from home_library.providers.playwright_site_search import (
+ fetch_from_playwright_site_search,
+)
+from home_library.providers.rsl_rkp import fetch_from_rsl_rkp
+from home_library.providers.yandex import fetch_from_yandex
logger = logging.getLogger(__name__)
@@ -41,10 +47,16 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None:
logger.info("Начинаю поиск книги по ISBN %s", isbn)
# Порядок приоритета: результат первого по индексу побеждает.
+ # РГБ — национальный депозитарий, для префикса 978-5-… покрывает
+ # больше, чем любой одиночный коммерческий каталог. Яндекс подключён
+ # опционально через env YANDEX_ENABLED=1 — без флага провайдер
+ # мгновенно возвращает None и не замедляет гонку.
primary_providers = [
+ ("РГБ", fetch_from_rsl_rkp),
("Лабиринт", fetch_from_labirint),
("Piter", fetch_from_piter),
("Google Books", fetch_from_google_books),
+ ("Яндекс", fetch_from_yandex),
]
async with httpx.AsyncClient(timeout=10) as client:
@@ -63,8 +75,14 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None:
logger.info("ISBN %s найден через %s: %r", isbn, name, result.title)
return result
+ logger.info("Основные провайдеры не нашли ISBN %s, пробую Playwright site search", isbn)
+ book = await fetch_from_playwright_site_search(isbn, client)
+ if book:
+ logger.info("ISBN %s найден через Playwright site search: %r", isbn, book.title)
+ return book
+
# DuckDuckGo — менее надёжный fallback, запускаем отдельно
- logger.info("Основные провайдеры не нашли ISBN %s, пробую DuckDuckGo", isbn)
+ logger.info("Playwright site search не нашел ISBN %s, пробую DuckDuckGo", isbn)
book = await fetch_from_ddg(isbn, client)
if book:
logger.info("ISBN %s найден через DuckDuckGo: %r", isbn, book.title)
diff --git a/home_library/providers/playwright_site_search.py b/home_library/providers/playwright_site_search.py
new file mode 100644
index 0000000..f30700e
--- /dev/null
+++ b/home_library/providers/playwright_site_search.py
@@ -0,0 +1,270 @@
+"""Playwright fallback for site-specific ISBN search.
+
+Провайдер ходит напрямую на книжные сайты с поиском по ISBN через
+Playwright и вытаскивает книгу из серверного HTML без зависимости от
+поисковых движков.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import html
+import logging
+import os
+import re
+from typing import TYPE_CHECKING
+
+from home_library.domain.models import BookRecord
+from home_library.providers.labirint import _parse_labirint_book_page
+from home_library.providers.yandex import YANDEX_STEALTH_SETTLE_SECONDS, YANDEX_USER_AGENT
+
+if TYPE_CHECKING:
+ import httpx
+
+logger = logging.getLogger(__name__)
+
+PLAYWRIGHT_SITE_SEARCH_ENABLED_ENV = "PLAYWRIGHT_SITE_SEARCH_ENABLED"
+PLAYWRIGHT_SITE_TIMEOUT_MS = 20_000
+
+LIVELIB_SEARCH_URL = "https://www.livelib.ru/find/{isbn}"
+MYBOOK_SEARCH_URL = "https://mybook.ru/search/books/?q={isbn}"
+LABIRINT_SEARCH_URL = "https://www.labirint.ru/search/{isbn}/"
+
+BOOK_LINK_PATTERN = re.compile(r'href="(?P[^"]+)"')
+LIVELIB_RESULT_PATTERN = re.compile(
+ r'href="(?P/book/[^"]+)"[^>]*title="(?P[^"]+)"[^>]*>'
+ r"(?P[^<]+).*?"
+ r'href="(?P/author/[^"]+)"[^>]*title="(?P[^<]+)"',
+ re.DOTALL,
+)
+MYBOOK_RESULT_PATTERN = re.compile(
+ r'href="(?P/author/[^"#?]+/[^"#?]+/)"[^>]*>.*?'
+ r"]*>(?P
[^<]+?)
.*?"
+ r'href="(?P/author/[^"#?]+/)"[^>]*>.*?'
+ r"",
+ re.DOTALL,
+)
+LABIRINT_BOOK_URL_PATTERN = re.compile(r'href="(?P/books/\d+/)"')
+LABIRINT_RESULT_PATTERN = re.compile(
+ r'(?:class="product-title-link"[^>]*href|href)="(?P/books/\d+/)"[^>]*>\s*(?P[^<]+?)\s*.*?'
+ r'href="/authors/\d+/"[^>]*>\s*(?:)?(?P[^<]+?)(?:)?\s*.*?'
+ r'href="/pubhouse/\d+/"[^>]*>\s*(?P[^<]+?)\s*',
+ re.DOTALL,
+)
+
+
+def _clean_html_text(value: str) -> str:
+ """Нормализует текст, извлечённый из HTML."""
+ normalized = html.unescape(value)
+ normalized = re.sub(r"\s+", " ", normalized)
+ return normalized.strip(" \n\t\r\"'«»")
+
+
+def _normalize_href(href: str, base_url: str) -> str:
+ """Преобразует относительную ссылку в абсолютную."""
+ if href.startswith("http"):
+ return href
+ return f"{base_url.rstrip('/')}{href}"
+
+
+def _build_book_record(
+ *,
+ title: str,
+ author: str,
+ isbn: str,
+ link: str,
+ publisher: str = "",
+) -> BookRecord | None:
+ """Собирает ``BookRecord`` из сырых полей сайта."""
+ cleaned_title = _clean_html_text(title)
+ cleaned_author = _clean_html_text(author)
+ cleaned_publisher = _clean_html_text(publisher)
+ if not cleaned_title:
+ return None
+
+ return BookRecord(
+ title=cleaned_title,
+ author=cleaned_author,
+ publisher=cleaned_publisher,
+ topics="",
+ link=link,
+ isbn=isbn,
+ )
+
+
+def _parse_livelib_search_page(text: str, isbn: str) -> BookRecord | None:
+ """Извлекает первую книгу из HTML LiveLib поиска по ISBN."""
+ match = LIVELIB_RESULT_PATTERN.search(text)
+ if not match:
+ return None
+
+ return _build_book_record(
+ title=match.group("title") or match.group("full"),
+ author=match.group("author"),
+ isbn=isbn,
+ link=_normalize_href(match.group("href"), "https://www.livelib.ru"),
+ )
+
+
+def _parse_mybook_search_page(text: str, isbn: str) -> BookRecord | None:
+ """Извлекает первую книгу из HTML MyBook поиска по ISBN."""
+ match = MYBOOK_RESULT_PATTERN.search(text)
+ if not match:
+ return None
+
+ return _build_book_record(
+ title=match.group("title"),
+ author=match.group("author"),
+ isbn=isbn,
+ link=_normalize_href(match.group("href"), "https://mybook.ru"),
+ )
+
+
+def _extract_labirint_book_url(text: str) -> str | None:
+ """Вытаскивает URL первой карточки книги из поиска Лабиринта."""
+ match = LABIRINT_BOOK_URL_PATTERN.search(text)
+ if not match:
+ return None
+ return _normalize_href(match.group("href"), "https://www.labirint.ru")
+
+
+def _parse_labirint_search_page(text: str, isbn: str) -> BookRecord | None:
+ """Извлекает первую книгу из HTML поиска Лабиринта."""
+ match = LABIRINT_RESULT_PATTERN.search(text)
+ if not match:
+ return None
+
+ return _build_book_record(
+ title=match.group("title"),
+ author=match.group("author"),
+ publisher=match.group("publisher"),
+ isbn=isbn,
+ link=_normalize_href(match.group("href"), "https://www.labirint.ru"),
+ )
+
+
+async def _open_page(context, url: str) -> str | None:
+ """Открывает страницу через Playwright и возвращает её HTML."""
+ from playwright.async_api import Error as PlaywrightError # noqa: PLC0415
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError # noqa: PLC0415
+
+ page = await context.new_page()
+ try:
+ await page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_SITE_TIMEOUT_MS)
+ with contextlib.suppress(PlaywrightTimeoutError):
+ await page.wait_for_load_state("networkidle", timeout=PLAYWRIGHT_SITE_TIMEOUT_MS)
+ await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS)
+ return await page.content()
+ except PlaywrightError as exc:
+ logger.warning(
+ "Playwright site search: ошибка загрузки %s: %s — %s",
+ url,
+ type(exc).__name__,
+ exc,
+ )
+ return None
+ finally:
+ await page.close()
+
+
+async def _fetch_from_livelib(context, isbn: str) -> BookRecord | None:
+ """Ищет книгу по ISBN на LiveLib."""
+ text = await _open_page(context, LIVELIB_SEARCH_URL.format(isbn=isbn))
+ if text is None:
+ return None
+ return _parse_livelib_search_page(text, isbn)
+
+
+async def _fetch_from_mybook(context, isbn: str) -> BookRecord | None:
+ """Ищет книгу по ISBN на MyBook."""
+ text = await _open_page(context, MYBOOK_SEARCH_URL.format(isbn=isbn))
+ if text is None:
+ return None
+ return _parse_mybook_search_page(text, isbn)
+
+
+async def _fetch_from_labirint_playwright(context, isbn: str) -> BookRecord | None:
+ """Ищет книгу по ISBN на Лабиринте через Playwright."""
+ search_html = await _open_page(context, LABIRINT_SEARCH_URL.format(isbn=isbn))
+ if search_html is None:
+ return None
+
+ if book := _parse_labirint_search_page(search_html, isbn):
+ return book
+
+ book_url = _extract_labirint_book_url(search_html)
+ if book_url is None:
+ return None
+
+ book_html = await _open_page(context, book_url)
+ if book_html is None:
+ return None
+
+ return _parse_labirint_book_page(book_html, book_url, isbn)
+
+
+async def fetch_from_playwright_site_search(
+ isbn: str,
+ _client: httpx.AsyncClient,
+) -> BookRecord | None:
+ """Последний Playwright fallback по книжным сайтам.
+
+ Провайдер включается только при ``PLAYWRIGHT_SITE_SEARCH_ENABLED=1``.
+ Сначала проверяет LiveLib и MyBook, затем отдельно повторяет
+ Labirint через браузерный контекст.
+ """
+ if os.environ.get(PLAYWRIGHT_SITE_SEARCH_ENABLED_ENV) != "1":
+ return None
+
+ try:
+ from playwright.async_api import async_playwright # noqa: PLC0415
+ from playwright_stealth import Stealth # noqa: PLC0415
+ except ImportError as exc:
+ logger.warning(
+ "PLAYWRIGHT_SITE_SEARCH_ENABLED=1, но не установлен playwright/playwright-stealth: %s",
+ exc,
+ )
+ return None
+
+ try:
+ async with async_playwright() as pw:
+ browser = await pw.chromium.launch(
+ headless=True,
+ args=["--disable-blink-features=AutomationControlled"],
+ )
+ try:
+ context = await browser.new_context(
+ user_agent=YANDEX_USER_AGENT,
+ locale="ru-RU",
+ timezone_id="Europe/Moscow",
+ viewport={"width": 1280, "height": 900},
+ )
+ await Stealth().apply_stealth_async(context)
+
+ for site_name, provider in (
+ ("LiveLib", _fetch_from_livelib),
+ ("MyBook", _fetch_from_mybook),
+ ("Лабиринт (Playwright)", _fetch_from_labirint_playwright),
+ ):
+ book = await provider(context, isbn)
+ if book is not None:
+ logger.info(
+ "ISBN %s найден через %s Playwright fallback: %r",
+ isbn,
+ site_name,
+ book.title,
+ )
+ return book
+ finally:
+ await browser.close()
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "Playwright site search: ошибка поиска ISBN %s: %s — %s",
+ isbn,
+ type(exc).__name__,
+ exc,
+ )
+ return None
+
+ return None
diff --git a/home_library/providers/rsl_rkp.py b/home_library/providers/rsl_rkp.py
new file mode 100644
index 0000000..7afc1a2
--- /dev/null
+++ b/home_library/providers/rsl_rkp.py
@@ -0,0 +1,296 @@
+"""Российская государственная библиотека (РКП) provider adapter.
+
+Ищет книгу по ISBN через каталог `search.rsl.ru`. РГБ — национальный
+депозитарий, и при префиксе ISBN ``978-5-…`` обеспечивает самое
+полное покрытие в ру-сегменте по сравнению со всеми остальными
+источниками. Endpoint требует CSRF-токен и cookies, которые
+выдаются одним bootstrap-`GET`.
+
+Поведение управляется флагом ``RSL_RKP_ENABLED``: значение ``"0"``
+выключает провайдера. Любое другое значение или отсутствие
+переменной — провайдер активен по умолчанию (CSRF-bootstrap дешёвый,
+сайт не требует Playwright).
+"""
+
+from __future__ import annotations
+
+import html
+import logging
+import os
+import re
+from http import HTTPStatus
+
+import httpx
+
+from home_library import config
+from home_library.domain.models import BookRecord
+
+logger = logging.getLogger(__name__)
+
+CSRF_META_PATTERN = re.compile(r'name="csrf-token"\s+content="([^"]+)"')
+SEARCH_CONTAINER_PATTERN = re.compile(
+ r'\d+)"'
+ r"(?P.*?)"
+ r"(?=
\s*
\s*
\s*\s*$)",
+ re.DOTALL,
+)
+MAIN_INFO_PATTERN = re.compile(
+ r'class="js-item-maininfo"[^>]*>(?P.*?)',
+ re.DOTALL,
+)
+AUTHOR_INFO_PATTERN = re.compile(
+ r'class="js-item-authorinfo"[^>]*>(?P.*?)',
+ re.DOTALL,
+)
+TOPIC_PATTERN = re.compile(
+ r'rsl-item-otherinfo-item-name">\s*Тема\s*\s*'
+ r'(?P.*?)
',
+ re.DOTALL,
+)
+
+MATERIAL_MARKER_PATTERN = re.compile(r"\[(?:Текст|Электронный\s+ресурс|Изоматериал|Видео|Звукозапись)\]")
+AGE_RATING_PATTERN = re.compile(r"\[\d+\+\]")
+SQUARE_BRACKET_NOTE_PATTERN = re.compile(r"\[[^\[\]]+\]")
+COPYRIGHT_PREFIX_PATTERN = re.compile(r"\bcop\.\s*", re.IGNORECASE)
+
+
+def _strip_html(text: str) -> str:
+ """Удаляет HTML-теги и нормализует пробелы."""
+ no_tags = re.sub(r"<[^>]+>", "", text)
+ return re.sub(r"\s+", " ", html.unescape(no_tags)).strip()
+
+
+def _normalize_segment(segment: str) -> str:
+ """Чистит сегмент библиографического описания."""
+ cleaned = MATERIAL_MARKER_PATTERN.sub("", segment)
+ cleaned = AGE_RATING_PATTERN.sub("", cleaned)
+ cleaned = COPYRIGHT_PREFIX_PATTERN.sub("", cleaned)
+ cleaned = re.sub(r"\s+", " ", cleaned)
+ return cleaned.strip(" .,:;\"'«»")
+
+
+def parse_bibliographic_description(text: str) -> dict[str, str]:
+ """Разбирает строку описания книги в формате ГОСТ 7.1.
+
+ Args:
+ text: исходная строка с библиографическим описанием РГБ.
+
+ Returns:
+ Словарь с ключами ``title``, ``author``, ``publisher``, ``year``,
+ ``series``. Отсутствующее поле — пустая строка.
+
+ Example::
+
+ >>> parse_bibliographic_description(
+ ... "Никто не заплачет : [16+] / Полина Дашкова. - "
+ ... "Москва : АСТ, cop. 2020. - 411 с."
+ ... )["author"]
+ 'Полина Дашкова'
+ """
+ if not text:
+ return {"title": "", "author": "", "publisher": "", "year": "", "series": ""}
+
+ normalized = re.sub(r"\s+", " ", text).strip()
+
+ # Серия — последний блок в круглых скобках после ". - "
+ series = ""
+ series_match = re.search(r"\.\s*-\s*\(([^()]+)\)", normalized)
+ if series_match:
+ series = _normalize_segment(series_match.group(1))
+
+ # Откидываем хвост от первого ". - " (это импринт + физ. характеристики + серия)
+ head_tail = re.split(r"\.\s*-\s*", normalized, maxsplit=1)
+ head = head_tail[0]
+ tail = head_tail[1] if len(head_tail) > 1 else ""
+
+ # Title / authors из head: до '/' — title, после — авторы (до ';' или конца)
+ if "/" in head:
+ title_part, author_part = head.split("/", 1)
+ else:
+ title_part, author_part = head, ""
+
+ # Если title содержит ' : ' — берём до первого подзаголовка-в-кавычках/возрастного маркера
+ title_clean = title_part
+ title_clean = re.sub(r"\s*:\s*\[[^\[\]]+\].*$", "", title_clean)
+ title_clean = re.sub(r"\s*:\s*[^/]*$", "", title_clean) if " : " in title_clean else title_clean
+ title = _normalize_segment(title_clean)
+
+ # Author: всё до первого ';' (там обычно идут переводчики/иллюстраторы)
+ author_main = author_part.split(";", 1)[0]
+ author = _normalize_segment(author_main)
+
+ publisher = ""
+ year = ""
+ if tail:
+ imprint = tail
+ imprint = re.split(r"\.\s*-\s*", imprint, maxsplit=1)[0]
+ imprint = COPYRIGHT_PREFIX_PATTERN.sub("", imprint)
+ match = re.match(
+ r"\s*(?P[^:,]+?)\s*:\s*(?P.+?)\s*,\s*(?P\d{4})\s*\.?",
+ imprint,
+ )
+ if match:
+ publisher = _normalize_segment(match.group("publisher"))
+ year = match.group("year")
+ else:
+ # Без года: "<Город> : <Издатель>."
+ no_year_match = re.match(r"\s*(?P[^:,]+?)\s*:\s*(?P[^.,]+?)\s*\.?\s*$", imprint)
+ if no_year_match:
+ publisher = _normalize_segment(no_year_match.group("publisher"))
+
+ return {
+ "title": title,
+ "author": author,
+ "publisher": publisher,
+ "year": year,
+ "series": series,
+ }
+
+
+def parse_search_content(content_html: str) -> list[dict[str, str]]:
+ """Извлекает карточки книг из поля ``content`` ответа ``ajax-search``.
+
+ Args:
+ content_html: HTML-фрагмент из JSON-ответа РГБ.
+
+ Returns:
+ Список словарей по каждой найденной карточке с ключами
+ ``record_id``, ``main_info``, ``author_info``, ``topic``.
+ """
+ if not content_html:
+ return []
+
+ cards: list[dict[str, str]] = []
+ for match in SEARCH_CONTAINER_PATTERN.finditer(content_html):
+ record_id = match.group("record_id")
+ body = match.group("body")
+ main_match = MAIN_INFO_PATTERN.search(body)
+ author_match = AUTHOR_INFO_PATTERN.search(body)
+ topic_match = TOPIC_PATTERN.search(body)
+ cards.append(
+ {
+ "record_id": record_id,
+ "main_info": _strip_html(main_match.group("value")) if main_match else "",
+ "author_info": _strip_html(author_match.group("value")) if author_match else "",
+ "topic": _strip_html(topic_match.group("value")) if topic_match else "",
+ },
+ )
+ return cards
+
+
+def _is_enabled() -> bool:
+ """Возвращает ``True``, когда провайдер не выключен через env."""
+ return os.environ.get(config.RSL_RKP_ENABLED_ENV, "1") != "0"
+
+
+async def _bootstrap_csrf(client: httpx.AsyncClient) -> str | None:
+ """Получает CSRF-токен и расставляет session cookies для дальнейшего поиска."""
+ bootstrap_url = config.RSL_RKP_BASE_URL + config.RSL_RKP_BOOTSTRAP_PATH
+ resp = await client.get(bootstrap_url, follow_redirects=True)
+ if resp.status_code != HTTPStatus.OK:
+ return None
+ match = CSRF_META_PATTERN.search(resp.text)
+ return match.group(1) if match else None
+
+
+def _build_search_payload(isbn: str) -> dict[str, str | list[str]]:
+ return {
+ "SearchFilterForm[elfunds]": "0",
+ "SearchFilterForm[nofile]": "0",
+ "SearchFilterForm[accessFree]": "0",
+ "SearchFilterForm[accessLimited]": "0",
+ "SearchFilterForm[pubyearfrom]": "",
+ "SearchFilterForm[pubyearto]": "",
+ "SearchFilterForm[digitizedDateFrom]": "",
+ "SearchFilterForm[digitizedDateTo]": "",
+ "SearchFilterForm[enterdatefrom]": "",
+ "SearchFilterForm[enterdateto]": "",
+ "SearchFilterForm[sortby]": "default",
+ "SearchFilterForm[page]": "1",
+ "SearchFilterForm[inDodRoom]": "0",
+ "SearchFilterForm[search]": isbn,
+ "SearchFilterForm[fulltext]": "0",
+ "SearchFilterForm[updatedFields][]": "search",
+ }
+
+
+async def _query_rsl(
+ isbn: str,
+ client: httpx.AsyncClient,
+) -> dict[str, object] | None:
+ """Делает bootstrap-GET и POST к ajax-search; возвращает dict или ``None``."""
+ try:
+ csrf = await _bootstrap_csrf(client)
+ except (httpx.HTTPError, OSError) as exc:
+ logger.warning("РГБ: bootstrap упал по ISBN %s: %s — %s", isbn, type(exc).__name__, exc)
+ return None
+ if not csrf:
+ logger.warning("РГБ: CSRF-токен не получен для ISBN %s", isbn)
+ return None
+
+ search_url = config.RSL_RKP_BASE_URL + config.RSL_RKP_AJAX_SEARCH_PATH
+ try:
+ resp = await client.post(
+ search_url,
+ data=_build_search_payload(isbn),
+ headers={
+ "X-CSRF-Token": csrf,
+ "X-Requested-With": "XMLHttpRequest",
+ "Referer": config.RSL_RKP_BASE_URL + "/ru/search",
+ "Accept": "application/json, text/javascript, */*; q=0.01",
+ },
+ )
+ except (httpx.HTTPError, OSError) as exc:
+ logger.warning("РГБ: сеть упала по ISBN %s: %s — %s", isbn, type(exc).__name__, exc)
+ return None
+
+ if resp.status_code != HTTPStatus.OK:
+ logger.warning("РГБ: HTTP %s по ISBN %s", resp.status_code, isbn)
+ return None
+
+ try:
+ payload = resp.json()
+ except ValueError as exc:
+ logger.warning("РГБ: невалидный JSON по ISBN %s: %s", isbn, exc)
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+async def fetch_from_rsl_rkp(isbn: str, client: httpx.AsyncClient) -> BookRecord | None:
+ """Ищет книгу в каталоге РГБ по ISBN.
+
+ Args:
+ isbn: ISBN-код книги (10 или 13 цифр).
+ client: Async HTTP-клиент (``httpx.AsyncClient``).
+
+ Returns:
+ ``BookRecord`` с найденными метаданными или ``None``, если книга
+ не найдена или провайдер выключен.
+ """
+ if not _is_enabled():
+ return None
+
+ payload = await _query_rsl(isbn, client)
+ if payload is None:
+ return None
+ if not payload.get("TotalHits"):
+ return None
+
+ cards = parse_search_content(str(payload.get("content") or ""))
+ if not cards:
+ return None
+
+ card = cards[0]
+ parsed = parse_bibliographic_description(card["main_info"])
+ title = parsed["title"]
+ if not title:
+ return None
+
+ return BookRecord(
+ title=title,
+ author=parsed["author"] or card["author_info"].rstrip("."),
+ publisher=parsed["publisher"],
+ topics=card["topic"],
+ link=config.RSL_RKP_RECORD_URL_TEMPLATE.format(record_id=card["record_id"]),
+ isbn=isbn,
+ )
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/home_library/providers/yandex.py b/home_library/providers/yandex.py
new file mode 100644
index 0000000..895c6b0
--- /dev/null
+++ b/home_library/providers/yandex.py
@@ -0,0 +1,331 @@
+"""Yandex provider adapter (Playwright + stealth).
+
+Опциональный провайдер: идёт в ya.ru через headless Chromium с
+``playwright-stealth``, что обходит SmartCaptcha для части ISBN,
+которые не находятся в основных книжных каталогах.
+
+Активируется только при ``YANDEX_ENABLED=1`` и установленных
+``playwright`` и ``playwright-stealth``. Без флага провайдер тихо
+возвращает ``None``, бот работает как раньше.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import logging
+import os
+from typing import TYPE_CHECKING
+
+from home_library.domain.models import BookRecord
+from home_library.providers._serp_parser import extract_book_from_serp
+
+if TYPE_CHECKING:
+ import httpx
+
+logger = logging.getLogger(__name__)
+
+YANDEX_ENABLED_ENV = "YANDEX_ENABLED"
+YANDEX_SEARCH_URL = "https://ya.ru/search/?text=ISBN%3A+{isbn}"
+YANDEX_USER_AGENT = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
+)
+YANDEX_NAV_TIMEOUT_MS = 30_000
+YANDEX_NETWORK_IDLE_TIMEOUT_MS = 15_000
+YANDEX_STEALTH_SETTLE_SECONDS = 2
+YANDEX_H2_LIMIT = 10
+YANDEX_SNIPPET_LIMIT = 10
+YANDEX_LINK_LIMIT = 30
+YANDEX_ENRICH_LINK_LIMIT = 6
+YANDEX_RESULT_PAGE_TIMEOUT_MS = 12_000
+
+YANDEX_SERVICE_TITLE_MARKERS = (
+ "поиск isbn",
+ "search for books by isbn",
+ "search results",
+ "расширенный поиск",
+ "связанные запросы",
+)
+
+YANDEX_CAPTCHA_MARKERS = (
+ "вы не робот",
+ "smartcaptcha",
+ "captcha",
+)
+
+YANDEX_INTERNAL_HOST_MARKERS = (
+ "yandex.",
+ "ya.ru",
+)
+
+YANDEX_TITLE_NOISE_MARKERS = (
+ "издательства",
+ "обложка",
+ "отзывы",
+ "рецензии",
+ "краткое содержание",
+ "где купить",
+)
+
+
+def _looks_like_captcha(title: str, html_text: str) -> bool:
+ """Определяет, что Yandex показал страницу SmartCaptcha."""
+ lowered_title = title.lower()
+ lowered_html = html_text.lower()
+ return any(marker in lowered_title or marker in lowered_html for marker in YANDEX_CAPTCHA_MARKERS)
+
+
+def _filter_service_titles(titles: list[str]) -> list[str]:
+ """Убирает из списка h2-текстов служебные блоки Yandex."""
+ cleaned: list[str] = []
+ for raw in titles:
+ lowered = raw.strip().lower()
+ if not lowered:
+ continue
+ if any(marker in lowered for marker in YANDEX_SERVICE_TITLE_MARKERS):
+ continue
+ cleaned.append(raw.strip())
+ return cleaned
+
+
+def _parse_yandex_serp(
+ titles: list[str],
+ snippets: list[str],
+ isbn: str,
+) -> BookRecord | None:
+ """Разбирает результаты Yandex SERP в ``BookRecord``.
+
+ Тонкая обёртка над общим ``extract_book_from_serp``: просто
+ выбрасывает служебные блоки Yandex перед парсингом.
+ """
+ return extract_book_from_serp(
+ _filter_service_titles(titles),
+ isbn,
+ snippets=snippets,
+ )
+
+
+def _needs_result_page_enrichment(book: BookRecord | None, titles: list[str]) -> bool:
+ """Определяет, стоит ли добирать метаданные со страниц результатов."""
+ if book is None:
+ return True
+ if not book.author:
+ return True
+
+ return any(("..." in raw_title or "…" in raw_title) and book.title in raw_title for raw_title in titles)
+
+
+def _merge_result_page_book(base_book: BookRecord | None, enriched_book: BookRecord | None) -> BookRecord | None:
+ """Мержит базовый результат SERP с более полными метаданными страницы."""
+ if base_book is None:
+ return enriched_book
+ if enriched_book is None:
+ return base_book
+
+ def title_rank(title: str) -> tuple[int, int]:
+ lowered = title.lower()
+ penalty = sum(marker in lowered for marker in YANDEX_TITLE_NOISE_MARKERS)
+ return penalty, -len(title)
+
+ better_title = min((base_book.title, enriched_book.title), key=title_rank)
+
+ return base_book.model_copy(
+ update={
+ "title": better_title,
+ "author": enriched_book.author or base_book.author,
+ },
+ )
+
+
+def _is_external_result_link(href: str | None) -> bool:
+ """Оставляет только внешние ссылки на карточки/страницы книг."""
+ if not href or not href.startswith("http"):
+ return False
+ lowered = href.lower()
+ return not any(marker in lowered for marker in YANDEX_INTERNAL_HOST_MARKERS)
+
+
+async def _collect_result_page_candidates(page) -> list[tuple[str, str]]:
+ """Собирает верхние внешние ссылки из поисковой выдачи Yandex."""
+ links = page.locator("a[href]")
+ count = min(await links.count(), YANDEX_LINK_LIMIT)
+ candidates: list[tuple[str, str]] = []
+ seen_hrefs: set[str] = set()
+ for index in range(count):
+ link = links.nth(index)
+ href = await link.get_attribute("href")
+ if not _is_external_result_link(href) or href in seen_hrefs:
+ continue
+ text = (await link.inner_text()).strip()
+ if not text:
+ continue
+ candidates.append((text, href))
+ seen_hrefs.add(href)
+ if len(candidates) >= YANDEX_ENRICH_LINK_LIMIT:
+ break
+ return candidates
+
+
+async def _fetch_book_from_result_pages(context, isbn: str, result_links: list[tuple[str, str]]) -> BookRecord | None:
+ """Пробует добрать title/author с верхних страниц результатов поиска."""
+ from playwright.async_api import Error as PlaywrightError # noqa: PLC0415
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError # noqa: PLC0415
+
+ best_book: BookRecord | None = None
+ for link_text, href in result_links:
+ page = await context.new_page()
+ try:
+ await page.goto(
+ href,
+ wait_until="domcontentloaded",
+ timeout=YANDEX_RESULT_PAGE_TIMEOUT_MS,
+ )
+ with contextlib.suppress(PlaywrightTimeoutError):
+ await page.wait_for_load_state(
+ "networkidle",
+ timeout=YANDEX_RESULT_PAGE_TIMEOUT_MS,
+ )
+
+ h1_titles = [text.strip() for text in await page.locator("h1").all_text_contents() if text.strip()]
+ titles = [*h1_titles, link_text]
+ if not h1_titles:
+ titles.insert(0, (await page.title()).strip())
+
+ snippets: list[str] = []
+ meta_description = page.locator('meta[name="description"]')
+ if await meta_description.count():
+ content = await meta_description.first.get_attribute("content")
+ if content:
+ snippets.append(content.strip())
+
+ author_texts = await page.locator(
+ 'a[href*="author"], a[href*="kauthor"], [itemprop="author"]',
+ ).all_text_contents()
+ snippets.extend(f"Автор {text.strip()}" for text in author_texts if text.strip())
+
+ book = extract_book_from_serp(titles, isbn, snippets=snippets)
+ if book is not None:
+ best_book = _merge_result_page_book(best_book, book)
+ except PlaywrightError as exc:
+ logger.warning(
+ "Ошибка Playwright при обогащении результата Yandex (ISBN %s, url=%s): %s — %s",
+ isbn,
+ href,
+ type(exc).__name__,
+ exc,
+ )
+ finally:
+ await page.close()
+
+ return best_book
+
+
+async def fetch_from_yandex(
+ isbn: str,
+ _client: httpx.AsyncClient,
+) -> BookRecord | None:
+ """Ищет книгу по ISBN через Yandex с помощью Playwright+stealth.
+
+ Аргумент ``_client`` не используется — Yandex нельзя брать через
+ обычный HTTP, нужен headless-браузер. Клиент остаётся в сигнатуре
+ ради совместимости с другими провайдерами в ``lookup.fetch_book_by_isbn``.
+
+ Guard-условия (любое возвращает ``None`` мгновенно):
+ - ``YANDEX_ENABLED`` не равен ``"1"``;
+ - ``playwright`` или ``playwright-stealth`` не установлены.
+ """
+ if os.environ.get(YANDEX_ENABLED_ENV) != "1":
+ return None
+
+ try:
+ from playwright.async_api import ( # noqa: PLC0415
+ Error as PlaywrightError,
+ )
+ from playwright.async_api import ( # noqa: PLC0415
+ TimeoutError as PlaywrightTimeoutError,
+ )
+ from playwright.async_api import ( # noqa: PLC0415
+ async_playwright,
+ )
+ from playwright_stealth import Stealth # noqa: PLC0415
+ except ImportError as exc:
+ logger.warning(
+ "YANDEX_ENABLED=1, но не установлен playwright/playwright-stealth: %s",
+ exc,
+ )
+ return None
+
+ url = YANDEX_SEARCH_URL.format(isbn=isbn)
+ try:
+ async with async_playwright() as pw:
+ browser = await pw.chromium.launch(
+ headless=True,
+ args=["--disable-blink-features=AutomationControlled"],
+ )
+ try:
+ context = await browser.new_context(
+ user_agent=YANDEX_USER_AGENT,
+ locale="ru-RU",
+ timezone_id="Europe/Moscow",
+ viewport={"width": 1280, "height": 900},
+ )
+ await Stealth().apply_stealth_async(context)
+ page = await context.new_page()
+ await page.goto(
+ url,
+ wait_until="domcontentloaded",
+ timeout=YANDEX_NAV_TIMEOUT_MS,
+ )
+ with contextlib.suppress(PlaywrightTimeoutError):
+ await page.wait_for_load_state(
+ "networkidle",
+ timeout=YANDEX_NETWORK_IDLE_TIMEOUT_MS,
+ )
+ await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS)
+
+ page_title = await page.title()
+ page_html = await page.content()
+ if _looks_like_captcha(page_title, page_html):
+ logger.warning(
+ "Yandex вернул SmartCaptcha для ISBN %s (title=%r)",
+ isbn,
+ page_title,
+ )
+ return None
+
+ titles = (await page.locator("h2").all_text_contents())[:YANDEX_H2_LIMIT]
+ snippets = (
+ await page.locator(
+ "div.OrganicTextContentSpan, .organic__text, .VanillaReact",
+ ).all_text_contents()
+ )[:YANDEX_SNIPPET_LIMIT]
+ result_links = await _collect_result_page_candidates(page)
+
+ book = _parse_yandex_serp(titles, snippets, isbn)
+ if _needs_result_page_enrichment(book, titles):
+ enriched_book = await _fetch_book_from_result_pages(
+ context,
+ isbn,
+ result_links,
+ )
+ book = _merge_result_page_book(book, enriched_book)
+ return book
+ finally:
+ await browser.close()
+ except PlaywrightError as exc:
+ logger.warning(
+ "Ошибка Playwright при поиске через Yandex (ISBN %s): %s — %s",
+ isbn,
+ type(exc).__name__,
+ exc,
+ )
+ return None
+ except (KeyError, TypeError, ValueError) as exc:
+ logger.warning(
+ "Ошибка парсинга результатов Yandex (ISBN %s): %s — %s",
+ isbn,
+ type(exc).__name__,
+ exc,
+ )
+ return None
diff --git a/pyproject.toml b/pyproject.toml
index 16f6fdb..3863c35 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,3 +57,7 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["openpyxl", "openpyxl.*"]
ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["playwright.*", "playwright_stealth"]
+ignore_missing_imports = true
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 4e2247f..53a0168 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,3 +5,9 @@ mypy==1.20.1
pytest==9.0.3
pytest-asyncio==1.3.0
pytest-cov==7.1.0
+
+# Optional: опциональный Yandex-провайдер через headless Chromium +
+# playwright-stealth. После установки выполнить `playwright install chromium`
+# и включить через `export YANDEX_ENABLED=1`.
+playwright>=1.58
+playwright-stealth>=2.0
diff --git a/scripts/yandex_lookup_debug.py b/scripts/yandex_lookup_debug.py
new file mode 100644
index 0000000..7cf2e41
--- /dev/null
+++ b/scripts/yandex_lookup_debug.py
@@ -0,0 +1,101 @@
+"""Отладочный прогон Yandex lookup через Playwright.
+
+Печатает сырые заголовки выдачи, сниппеты, верхние ссылки и результат
+текущего парсера для указанного ISBN.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import pathlib
+import sys
+
+import httpx
+from playwright.async_api import TimeoutError as PlaywrightTimeoutError
+from playwright.async_api import async_playwright
+from playwright_stealth import Stealth
+
+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
+
+from home_library.providers.yandex import (
+ YANDEX_NAV_TIMEOUT_MS,
+ YANDEX_NETWORK_IDLE_TIMEOUT_MS,
+ YANDEX_SEARCH_URL,
+ YANDEX_SNIPPET_LIMIT,
+ YANDEX_STEALTH_SETTLE_SECONDS,
+ YANDEX_USER_AGENT,
+ fetch_from_yandex,
+)
+
+
+async def main(isbn: str) -> None:
+ os.environ["YANDEX_ENABLED"] = "1"
+
+ async with httpx.AsyncClient(timeout=10) as client:
+ parsed = await fetch_from_yandex(isbn, client)
+ print("PARSED:")
+ print(parsed.model_dump() if parsed else None)
+
+ async with async_playwright() as pw:
+ browser = await pw.chromium.launch(
+ headless=True,
+ args=["--disable-blink-features=AutomationControlled"],
+ )
+ try:
+ context = await browser.new_context(
+ user_agent=YANDEX_USER_AGENT,
+ locale="ru-RU",
+ timezone_id="Europe/Moscow",
+ viewport={"width": 1280, "height": 900},
+ )
+ await Stealth().apply_stealth_async(context)
+
+ page = await context.new_page()
+ await page.goto(
+ YANDEX_SEARCH_URL.format(isbn=isbn),
+ wait_until="domcontentloaded",
+ timeout=YANDEX_NAV_TIMEOUT_MS,
+ )
+ try:
+ await page.wait_for_load_state(
+ "networkidle",
+ timeout=YANDEX_NETWORK_IDLE_TIMEOUT_MS,
+ )
+ except PlaywrightTimeoutError:
+ pass
+ await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS)
+
+ print("\nPAGE TITLE:")
+ print(await page.title())
+
+ print("\nH2:")
+ for item in (await page.locator("h2").all_text_contents())[:15]:
+ print(repr(item))
+
+ print("\nSNIPPETS:")
+ for item in (
+ await page.locator(
+ "div.OrganicTextContentSpan, .organic__text, .VanillaReact",
+ ).all_text_contents()
+ )[:YANDEX_SNIPPET_LIMIT]:
+ print(repr(item))
+
+ print("\nLINKS:")
+ links = page.locator("a[href]")
+ count = min(await links.count(), 40)
+ for index in range(count):
+ link = links.nth(index)
+ text = (await link.inner_text()).strip()
+ href = await link.get_attribute("href")
+ if text:
+ print({"text": text, "href": href})
+ finally:
+ await browser.close()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ msg = "Usage: venv/bin/python scripts/yandex_lookup_debug.py "
+ raise SystemExit(msg)
+ asyncio.run(main(sys.argv[1]))
diff --git a/tests/fixtures/rsl_search_found.json b/tests/fixtures/rsl_search_found.json
new file mode 100644
index 0000000..ea132df
--- /dev/null
+++ b/tests/fixtures/rsl_search_found.json
@@ -0,0 +1,8 @@
+{
+ "TotalHits": 1,
+ "TotalHitsExact": true,
+ "PageNumber": 1,
+ "PageSize": 10,
+ "MaxPage": 1,
+ "content": "\n\n
\n
\n
\n
Возможно, вы имели в виду ?\n
\n
\n Запрос не дал результатов. Произведён поиск по запросу .\n
\n
\n
\n
\n\n\n\n Результатов: 1, страница 1 / 1 (0.173 сек.)\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n
\n \n
\n
\n Документ находится в открытом доступе в полном объёме. Вы можете ознакомиться и работать с ним бесплатно в нашем Просмотрщике.
\n
\n Читать онлайн \n Заказ копии фрагмента
\n
\n
\n
\n
\n\n\n\n\n\n
\n\n\n
\n\n\n\n\n
\n\n
\n\n
\n
\n
\n
\n
Дашкова, Полина
\n
Никто не заплачет : [роман : 16+]
\n
\n
\n\n
\n\n
\n
Дашкова, Полина (1960-).\n
\n Никто не заплачет : [роман : 16+] / Полина Дашкова. - Москва : АСТ, cop. 2020. - 411, [2] с.; 22 см. - (Полина Дашкова - лучшая среди лучших).; ISBN 978-5-17-122183-6 : 2000 экз.\n \n \n\n\t\t\t
\n\t\t\t
\n\t\t\t\tШифр хранения\t\t\t
\n\t\t\t
\n\t\t\t \t\t\t
\n\t\t\t FB 10 20-40/219\n\t\t\t \t\t\t
\n\t\t\t \t\t\t
\n\t\t\t FB 10 20-40/145\n\t\t\t \t\t\t
\n\t\t\t \t\t\t
\n \t
\n\t\t\t\t
\n\t\t\t
\n\t\t\t\tТема\t\t\t
\n\t\t\t
\n \t\t \t\t Филологические науки. Художественная литература -- Российская Федерация -- Русская литература -- с 1991 г. -- Произведения художественной литературы -- Художественная проза -- Романы. Повести. Рассказы -- Приключенческие и детективные романы, повести, рассказы\n\t\t
\n\t\t \t\t\t
\n\t\t
\n\t\t\t\t\t\t\t\t
\n \n больше\n \n
\n\n \n \n\n \n
\n\n
\n\n
\n\n
\n\n
\n\n \n
\n
\n\n
\n\n\n\n
\n
\n\n"
+}
\ No newline at end of file
diff --git a/tests/fixtures/rsl_search_notfound.json b/tests/fixtures/rsl_search_notfound.json
new file mode 100644
index 0000000..8ec3f80
--- /dev/null
+++ b/tests/fixtures/rsl_search_notfound.json
@@ -0,0 +1,8 @@
+{
+ "TotalHits": 0,
+ "TotalHitsExact": true,
+ "PageNumber": 1,
+ "PageSize": 10,
+ "MaxPage": 1,
+ "content": "\n\n
\n
\n
\n
Возможно, вы имели в виду ?\n
\n
\n Запрос не дал результатов. Произведён поиск по запросу .\n
\n
\n
\n
\n\n\n\n Результатов: 0, страница 1 / 1 (0.162 сек.)\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n
\n \n
\n
\n Документ находится в открытом доступе в полном объёме. Вы можете ознакомиться и работать с ним бесплатно в нашем Просмотрщике.
\n
\n Читать онлайн \n Заказ копии фрагмента
\n
\n
\n
\n
\n\n\n\n\n\n
\n\n
\n
\n
\n По вашему запросу ничего не найдено. Вы можете изменить поиск или провести его по имидж-каталогам. \n
\n
\n\n
\n
\n\n"
+}
\ No newline at end of file
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 fa4e369..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
- 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"]
+ callback = await driver.click(lookup_callback)
+
+ 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,12 +253,429 @@ 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 driver.last_reply_text() == (
+ "Книга с ISBN 9789999999999 не найдена.\n"
+ "Напиши название, автора или часть — попробую с подсказкой (или /cancel)."
+ )
+ pending_hint = driver.user_data.get("pending_isbn_hint")
+ assert pending_hint is not None
+ assert pending_hint.isbn == "9789999999999"
+
+
+@pytest.mark.asyncio
+async def test_isbn_hint_flow_resolves_book_via_ddg(test_db, monkeypatch) -> None:
+ """Пользователь подсказал название — книга находится через DDG с контекстом."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> None:
+ return None
+
+ async def fake_fetch_from_ddg_with_context(isbn, hint, _client):
+ assert isbn == "9785961449136"
+ assert hint == "Мужские правила"
+ return BookRecord(
+ title="Мужские правила: Отношения, секс, психология",
+ author="Марк Мэнсон",
+ publisher="",
+ topics="",
+ link="",
+ isbn=isbn,
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785961449136")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ monkeypatch.setattr(
+ handlers.callbacks,
+ "fetch_from_ddg_with_context",
+ fake_fetch_from_ddg_with_context,
+ )
+ 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("Мужские правила")
+
+ assert "Мужские правила: Отношения, секс, психология" in driver.last_reply_text()
+ assert "Добавить в каталог?" in driver.last_reply_text()
+ assert driver.user_data.get("pending_isbn_hint") is None
+ assert driver.user_data.get("pending_book") is not None
+
+
+@pytest.mark.asyncio
+async def test_isbn_hint_flow_reports_missing_book_when_ddg_empty(
+ test_db,
+ monkeypatch,
+) -> None:
+ """Если DDG с подсказкой тоже ничего не дал — бот сообщает и очищает state."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> None:
+ return None
+
+ async def fake_fetch_from_ddg_with_context(_isbn, _hint, _client):
+ return None
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9789999999999")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ monkeypatch.setattr(
+ handlers.callbacks,
+ "fetch_from_ddg_with_context",
+ fake_fetch_from_ddg_with_context,
+ )
+ 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() == (
+ "По подсказке тоже ничего не нашлось. Попробуй другой ISBN или добавь книгу вручную."
+ )
+ assert driver.user_data.get("pending_isbn_hint") is None
+
+
+@pytest.mark.asyncio
+async def test_cancel_clears_pending_isbn_hint(test_db, monkeypatch) -> None:
+ """/cancel сбрасывает ожидание подсказки."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> None:
+ return None
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9789999999999")
+ 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)
+ assert driver.user_data.get("pending_isbn_hint") is not None
+
+ await driver.cancel()
+
+ assert driver.user_data.get("pending_isbn_hint") is 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 len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT
- assert driver.reply(1)["text"] == (
- "Книга с ISBN 9789999999999 не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск."
+ 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",
+ ),
)
- assert driver.user_data == {}
+ 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
diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py
index 7117348..8db4920 100644
--- a/tests/test_home_library_providers.py
+++ b/tests/test_home_library_providers.py
@@ -1,10 +1,35 @@
"""Tests for provider helpers."""
+import builtins as _builtins
+import io
+import json
+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 barcode, ddg, google_books, labirint, lookup, piter
+from home_library.providers import (
+ _serp_parser,
+ barcode,
+ ddg,
+ google_books,
+ labirint,
+ lookup,
+ piter,
+ playwright_site_search,
+ rsl_rkp,
+ title_page,
+ yandex,
+)
+
+FIXTURES_DIR = Path(__file__).parent / "fixtures"
+
+OTSU_THRESHOLD_MIN = 50
+OTSU_THRESHOLD_MAX = 200
+PREPROCESS_MIN_VARIANTS = 5
# ---------------------------------------------------------------------------
# Barcode
@@ -19,6 +44,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
# ---------------------------------------------------------------------------
@@ -231,28 +425,61 @@ def test_no_title_tag_returns_none(self):
# ---------------------------------------------------------------------------
-class TestCleanDdgTitle:
+class TestCleanTitle:
def test_strips_shop_suffix(self):
- assert ddg._clean_ddg_title("Война и мир - Ozon") == "Война и мир"
+ assert _serp_parser._clean_title("Война и мир - Ozon") == "Война и мир"
def test_strips_domain_prefix(self):
- assert ddg._clean_ddg_title("ozon.ru: Война и мир") == "Война и мир"
+ assert _serp_parser._clean_title("ozon.ru: Война и мир") == "Война и мир"
def test_strips_trailing_ellipsis(self):
- assert ddg._clean_ddg_title("Война и мир...") == "Война и мир"
+ assert _serp_parser._clean_title("Война и мир...") == "Война и мир"
def test_strips_multiple_separators(self):
- assert ddg._clean_ddg_title("Война и мир | Литрес - Ozon") == "Война и мир"
+ assert _serp_parser._clean_title("Война и мир | Литрес - Ozon") == "Война и мир"
+
+ def test_strips_alpina_series_article_and_age_marks(self):
+ assert (
+ _serp_parser._clean_title(
+ "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099",
+ )
+ == "От 0 до 5.Простые подсказки для умных родителей"
+ )
+
+ def test_strips_article_suffix_without_alpina_prefix(self):
+ assert _serp_parser._clean_title("Просто книга арт. 9785916717099") == "Просто книга"
+
+ def test_strips_marketplace_prefix_and_edition_suffix(self):
+ assert (
+ _serp_parser._clean_title(
+ "Нехудожественная книга Альпина От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.",
+ )
+ == "От 0 до 5: Простые подсказки для умных родителей"
+ )
+
+ def test_strips_kupit_v_suffix_and_publisher(self):
+ assert (
+ _serp_parser._clean_title("Мужские правила Марк Мэнсон - купить в Альпина.")
+ == "Мужские правила Марк Мэнсон"
+ )
+
+ def test_strips_publisher_suffix_alpina(self):
+ assert (
+ _serp_parser._clean_title(
+ "Мужские правила: Отношения, секс, психология Альпина...",
+ )
+ == "Мужские правила: Отношения, секс, психология"
+ )
-class TestExtractDdgBook:
+class TestExtractBookFromSerp:
def test_with_author_via_emdash(self):
titles = [
"Толстой — Война и мир",
"Война и мир | Лабиринт",
"Война и мир купить",
]
- result = ddg._extract_ddg_book(titles, "9785171234567")
+ result = _serp_parser.extract_book_from_serp(titles, "9785171234567")
assert result is not None
assert result.title == "Война и мир"
assert result.author == "Толстой"
@@ -262,21 +489,108 @@ def test_without_author(self):
"Война и мир | Лабиринт",
"Война и мир - Ozon",
]
- result = ddg._extract_ddg_book(titles, "9785171234567")
+ result = _serp_parser.extract_book_from_serp(titles, "9785171234567")
assert result is not None
assert result.title == "Война и мир"
assert result.author == ""
+ def test_extracts_author_from_snippet(self):
+ titles = [
+ "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099",
+ "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...",
+ ]
+ snippets = [
+ "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина",
+ ]
+
+ result = _serp_parser.extract_book_from_serp(
+ titles,
+ "9785916717099",
+ snippets=snippets,
+ )
+
+ assert result is not None
+ assert result.title == "От 0 до 5.Простые подсказки для умных родителей"
+ assert result.author == "Трейси Катчлоу"
+
+ def test_prefers_cleaner_title_when_frequency_is_equal(self):
+ titles = [
+ "Чистое название",
+ "Чистое название / служебный хвост изд ...",
+ ]
+
+ result = _serp_parser.extract_book_from_serp(titles, "9785171234567")
+
+ assert result is not None
+ assert result.title == "Чистое название"
+
+ def test_extracts_specific_alpina_non_fiction_book(self):
+ titles = [
+ "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley ...",
+ "От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.",
+ "Нехудожественная книга Альпина От 0 до 5. Простые подсказки для умных ...",
+ "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...",
+ "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099",
+ ]
+ snippets = [
+ "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley: 9785916717099: Trejsi Katchlou: Books",
+ "Купить Учебники.Словари.Энциклопедии От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т. Катчлоу Трейси 9785916717099",
+ "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина",
+ "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд-во, Автор 0, книга, isbn:9785916717099,",
+ ]
+
+ result = _serp_parser.extract_book_from_serp(
+ titles,
+ "9785916717099",
+ snippets=snippets,
+ )
+
+ assert result is not None
+ assert result.title == "От 0 до 5: Простые подсказки для умных родителей"
+ assert result.author == "Трейси Катчлоу"
+
+ def test_keeps_author_order_when_snippet_already_has_name_then_surname(self):
+ titles = [
+ "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук",
+ ]
+ snippets = [
+ "Автор Тамара Макклинток-Гринберг",
+ ]
+
+ result = _serp_parser.extract_book_from_serp(
+ titles,
+ "9785002143382",
+ snippets=snippets,
+ )
+
+ assert result is not None
+ assert result.author == "Тамара Макклинток-Гринберг"
+
+ def test_extracts_author_and_prefers_full_title_for_9785002143382(self):
+ titles = [
+ "Макклинток-Гринберг Тамара: Комплексное птср.",
+ "Книга Комплексное ПТСР. Как справиться с гневом...",
+ "Комплексное ПТСР. Как справиться с гневом и страхом...",
+ "МИФ. Комплексное ПТСР, мягкая обложка...",
+ "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук",
+ ]
+
+ result = _serp_parser.extract_book_from_serp(titles, "9785002143382")
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Тамара Макклинток-Гринберг"
+
def test_non_cyrillic_filtered_returns_none(self):
titles = [
"War and Peace - Amazon",
"War and Peace | eBay",
]
- result = ddg._extract_ddg_book(titles, "9785171234567")
+ result = _serp_parser.extract_book_from_serp(titles, "9785171234567")
assert result is None
def test_empty_titles_returns_none(self):
- result = ddg._extract_ddg_book([], "9785171234567")
+ result = _serp_parser.extract_book_from_serp([], "9785171234567")
assert result is None
@@ -301,6 +615,26 @@ async def test_success(self):
assert result is not None
assert result.title == "Война и мир"
+ @pytest.mark.asyncio
+ async def test_success_with_author_from_snippet(self):
+ html = """
+
+
+ АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099
+ Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина
+
+
+ """
+ transport = httpx.MockTransport(
+ lambda req: httpx.Response(200, text=html),
+ )
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await ddg.fetch_from_ddg("9785916717099", client)
+
+ assert result is not None
+ assert result.title == "От 0 до 5.Простые подсказки для умных родителей"
+ assert result.author == "Трейси Катчлоу"
+
@pytest.mark.asyncio
async def test_http_error_returns_none(self):
transport = httpx.MockTransport(
@@ -330,6 +664,325 @@ def raise_error(_request: httpx.Request) -> httpx.Response:
assert result is None
+class TestFetchFromDdgWithContext:
+ @pytest.mark.asyncio
+ async def test_query_uses_isbn_plus_hint(self):
+ seen_queries: list[str] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen_queries.append(request.url.params.get("q", ""))
+ return httpx.Response(200, text=DDG_SEARCH_HTML)
+
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(transport=transport) as client:
+ await ddg.fetch_from_ddg_with_context(
+ "9785171234567",
+ "Мужские правила",
+ client,
+ )
+ assert seen_queries == ["9785171234567 Мужские правила"]
+
+ @pytest.mark.asyncio
+ async def test_empty_hint_falls_back_to_isbn_only(self):
+ seen_queries: list[str] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen_queries.append(request.url.params.get("q", ""))
+ return httpx.Response(200, text=DDG_SEARCH_HTML)
+
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(transport=transport) as client:
+ await ddg.fetch_from_ddg_with_context("9785171234567", " ", client)
+ assert seen_queries == ["9785171234567"]
+
+ @pytest.mark.asyncio
+ async def test_alpina_9785961449136_regression(self):
+ html = """
+
+
+ Марк Мэнсон — Мужские правила: Отношения, секс, психология
+ Марк Мэнсон. Мужские правила: Отношения, секс, психология. ISBN 9785961449136.
+ ozon.ru: Мужские правила: Отношения, секс, психология...
+ Автор Мэнсон Марк Издательство Альпина Паблишер ISBN 9785961449136
+ Мужские правила: Отношения, секс, психология - Читай-город
+ Мужские правила: Отношения, секс, психология — Альпина Паблишер
+ Мужские правила: Отношения, секс, психология | Book24
+
+
+ """
+ transport = httpx.MockTransport(
+ lambda req: httpx.Response(200, text=html),
+ )
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await ddg.fetch_from_ddg_with_context(
+ "9785961449136",
+ "Мужские правила",
+ client,
+ )
+ assert result is not None
+ assert result.title == "Мужские правила: Отношения, секс, психология"
+ assert result.author == "Марк Мэнсон"
+ assert result.isbn == "9785961449136"
+
+ @pytest.mark.asyncio
+ async def test_http_error_returns_none(self):
+ transport = httpx.MockTransport(
+ lambda req: httpx.Response(500),
+ )
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await ddg.fetch_from_ddg_with_context(
+ "9785171234567",
+ "hint",
+ client,
+ )
+ assert result is None
+
+
+class TestParseYandexSerp:
+ def test_alpina_9785961449136_regression(self):
+ """Фикстура из живого прогона Playwright для проблемного ISBN."""
+ titles = [
+ "Поиск ISBN 9785961449136",
+ "Мужские правила Марк Мэнсон - купить в Альпина.",
+ "Мужские правила: Отношения, секс, психология Альпина...",
+ "Мужские правила Mark Manson",
+ ]
+ snippets = [
+ "Мужские правила. Марк Мэнсон. ISBN 9785961449136.",
+ "Тип нехудожественная книга Автор Мэнсон Марк издательство Альпина",
+ ]
+ result = yandex._parse_yandex_serp(titles, snippets, "9785961449136")
+ assert result is not None
+ assert result.title == "Мужские правила: Отношения, секс, психология"
+ assert result.author == "Марк Мэнсон"
+ assert result.isbn == "9785961449136"
+
+ def test_alpina_9785916717099_regression(self):
+ """Регрессия: книга, которая уже чинилась ранее через DDG."""
+ titles = [
+ "Search for books by ISBN 9785916717099",
+ "От 0 до 5: Простые подсказки для умных родителей",
+ "От 0 до 5: Простые подсказки для умных родителей",
+ "От 0 до 5 Альпина...",
+ ]
+ snippets = [
+ "Советы Трейси основаны на рекомендациях специалистов.",
+ "Автор Катчлоу Трейси Издательство Альпина",
+ ]
+ result = yandex._parse_yandex_serp(titles, snippets, "9785916717099")
+ assert result is not None
+ assert result.title == "От 0 до 5: Простые подсказки для умных родителей"
+ assert result.author == "Трейси Катчлоу"
+
+ def test_9785002143382_regression(self):
+ """Регрессия: Yandex находит книгу, но автор и полный title не должны теряться."""
+ titles = [
+ "Макклинток-Гринберг Тамара: Комплексное птср.",
+ "Книга Комплексное ПТСР. Как справиться с гневом...",
+ "Комплексное ПТСР. Как справиться с гневом и страхом...",
+ "МИФ. Комплексное ПТСР, мягкая обложка...",
+ "Комплексное ПТСР. Как справиться с гневом и страхом...",
+ "«Комплексное ПТСР. Как справиться с гневом и страхом...»",
+ "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук",
+ ]
+ snippets = [
+ "Макклинток-Гринберг Тамара: Комплексное птср.",
+ "Книга Комплексное ПТСР. Как справиться с гневом...",
+ "Комплексное ПТСР. Как справиться с гневом и страхом...",
+ ]
+
+ result = yandex._parse_yandex_serp(titles, snippets, "9785002143382")
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Тамара Макклинток-Гринберг"
+ assert result.isbn == "9785002143382"
+
+ def test_service_blocks_filtered(self):
+ titles = [
+ "Поиск ISBN 9785961449136",
+ "Search for books by ISBN",
+ "Связанные запросы",
+ ]
+ result = yandex._parse_yandex_serp(titles, [], "9785961449136")
+ assert result is None
+
+ def test_empty_titles(self):
+ result = yandex._parse_yandex_serp([], [], "9785961449136")
+ assert result is None
+
+
+class TestFetchFromYandex:
+ @pytest.mark.asyncio
+ async def test_disabled_without_flag(self, monkeypatch):
+ monkeypatch.delenv("YANDEX_ENABLED", raising=False)
+ async with httpx.AsyncClient() as client:
+ result = await yandex.fetch_from_yandex("9785961449136", client)
+ assert result is None
+
+
+class TestPlaywrightSiteSearchParsers:
+ def test_parse_livelib_search_page(self):
+ html = """
+
+ Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук
+ Тамара Макклинток-Гринберг
+ """
+
+ result = playwright_site_search._parse_livelib_search_page(
+ html,
+ "9785002143382",
+ )
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Тамара Макклинток-Гринберг"
+ assert result.link == "https://www.livelib.ru/book/1008960919-kompleksnoe-ptsr"
+
+ def test_parse_mybook_search_page(self):
+ html = """
+
+ Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук
+
+
+ Тамара Макклинток-Гринберг
+
+ """
+
+ result = playwright_site_search._parse_mybook_search_page(
+ html,
+ "9785002143382",
+ )
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Тамара Макклинток-Гринберг"
+ assert (
+ result.link
+ == "https://mybook.ru/author/tamara-makklintok-grinberg/kompleksnoe-ptsr-kak-spravitsya-s-gnevom-i-strahom/"
+ )
+
+ def test_extract_labirint_book_url(self):
+ html = 'Комплексное ПТСР'
+
+ result = playwright_site_search._extract_labirint_book_url(html)
+
+ assert result == "https://www.labirint.ru/books/980596/"
+
+ def test_parse_labirint_search_page(self):
+ html = """
+ Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук
+ Макклинток-Гринберг Тамара
+ Манн, Иванов и Фербер
+ """
+
+ result = playwright_site_search._parse_labirint_search_page(
+ html,
+ "9785002143382",
+ )
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Макклинток-Гринберг Тамара"
+ assert result.publisher == "Манн, Иванов и Фербер"
+ assert result.link == "https://www.labirint.ru/books/980596/"
+
+
+class TestFetchFromPlaywrightSiteSearch:
+ @pytest.mark.asyncio
+ async def test_disabled_without_flag(self, monkeypatch):
+ monkeypatch.delenv("PLAYWRIGHT_SITE_SEARCH_ENABLED", raising=False)
+ async with httpx.AsyncClient() as client:
+ result = await playwright_site_search.fetch_from_playwright_site_search(
+ "9785002143382",
+ client,
+ )
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_missing_playwright_returns_none(self, monkeypatch):
+ monkeypatch.setenv("PLAYWRIGHT_SITE_SEARCH_ENABLED", "1")
+ real_import = _builtins.__import__
+
+ def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0):
+ if name.startswith(("playwright", "playwright_stealth")):
+ raise ImportError(f"mocked missing {name}")
+ return real_import(name, globals_, locals_, fromlist, level)
+
+ monkeypatch.setattr(_builtins, "__import__", fake_import)
+ async with httpx.AsyncClient() as client:
+ result = await playwright_site_search.fetch_from_playwright_site_search(
+ "9785002143382",
+ client,
+ )
+ assert result is None
+
+
+class TestYandexEnrichmentHelpers:
+ def test_needs_result_page_enrichment_for_truncated_title(self):
+ book = BookRecord(
+ title="Комплексное ПТСР. Как справиться с гневом и страхом",
+ author="Тамара Макклинток-Гринберг",
+ isbn="9785002143382",
+ )
+ titles = [
+ "Комплексное ПТСР. Как справиться с гневом и страхом...",
+ "Макклинток-Гринберг Тамара: Комплексное птср.",
+ ]
+
+ assert yandex._needs_result_page_enrichment(book, titles) is True
+
+ def test_merge_result_page_book_prefers_longer_title_and_fills_author(self):
+ base_book = BookRecord(
+ title="Комплексное ПТСР. Как справиться с гневом и страхом",
+ author="",
+ isbn="9785002143382",
+ )
+ enriched_book = BookRecord(
+ title="Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук",
+ author="Тамара Макклинток-Гринберг",
+ isbn="9785002143382",
+ )
+
+ result = yandex._merge_result_page_book(base_book, enriched_book)
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+ assert result.author == "Тамара Макклинток-Гринберг"
+
+ def test_merge_result_page_book_ignores_longer_storefront_title(self):
+ base_book = BookRecord(
+ title="Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук",
+ author="Тамара Макклинток-Гринберг",
+ isbn="9785002143382",
+ )
+ enriched_book = BookRecord(
+ title="Книга издательства МИФ. Комплексное ПТСР, мягкая обложка (Макклинток-Гринберг Тамара) в Пинске",
+ author="Тамара Макклинток-Гринберг",
+ isbn="9785002143382",
+ )
+
+ result = yandex._merge_result_page_book(base_book, enriched_book)
+
+ assert result is not None
+ assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"
+
+ @pytest.mark.asyncio
+ async def test_missing_playwright_returns_none(self, monkeypatch):
+ monkeypatch.setenv("YANDEX_ENABLED", "1")
+ real_import = _builtins.__import__
+
+ def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0):
+ if name.startswith(("playwright", "playwright_stealth")):
+ raise ImportError(f"mocked missing {name}")
+ return real_import(name, globals_, locals_, fromlist, level)
+
+ monkeypatch.setattr(_builtins, "__import__", fake_import)
+ async with httpx.AsyncClient() as client:
+ result = await yandex.fetch_from_yandex("9785961449136", client)
+ assert result is None
+
+
# ---------------------------------------------------------------------------
# Lookup orchestrator
# ---------------------------------------------------------------------------
@@ -404,3 +1057,235 @@ async def mock_ddg(isbn, client):
result = await lookup.fetch_book_by_isbn("9781234567890")
assert result is not None
assert result.title == "From DDG"
+
+ @pytest.mark.asyncio
+ async def test_playwright_site_search_runs_before_ddg(self, monkeypatch):
+ async def mock_none(isbn, client):
+ return None
+
+ async def mock_site_search(isbn, client):
+ return BookRecord(title="From LiveLib", isbn=isbn)
+
+ async def mock_ddg(isbn, client):
+ return BookRecord(title="From DDG", isbn=isbn)
+
+ monkeypatch.setattr(lookup, "fetch_from_labirint", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_piter", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_google_books", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_yandex", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_playwright_site_search", mock_site_search)
+ monkeypatch.setattr(lookup, "fetch_from_ddg", mock_ddg)
+
+ result = await lookup.fetch_book_by_isbn("9781234567890")
+ assert result is not None
+ assert result.title == "From LiveLib"
+
+ @pytest.mark.asyncio
+ async def test_rsl_rkp_runs_first(self, monkeypatch):
+ """РГБ опрашивается первым в основной гонке."""
+
+ async def mock_none(isbn, client):
+ return None
+
+ async def mock_rsl(isbn, client):
+ return BookRecord(title="From RSL", isbn=isbn)
+
+ monkeypatch.setattr(lookup, "fetch_from_rsl_rkp", mock_rsl)
+ monkeypatch.setattr(lookup, "fetch_from_labirint", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_piter", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_google_books", mock_none)
+ monkeypatch.setattr(lookup, "fetch_from_yandex", mock_none)
+
+ result = await lookup.fetch_book_by_isbn("9785171221836")
+ assert result is not None
+ assert result.title == "From RSL"
+
+
+# ---------------------------------------------------------------------------
+# RSL/РКП provider
+# ---------------------------------------------------------------------------
+
+
+class TestRslBibParser:
+ """Парсер библиографического описания ГОСТ 7.1, как в ответе РГБ."""
+
+ def test_full_description_parses_title_author_publisher_year(self):
+ text = (
+ "Никто не заплачет : [роман : 16+] / Полина Дашкова. - Москва : "
+ "АСТ, cop. 2020. - 411, [2] с.; 22 см. - "
+ "(Полина Дашкова - лучшая среди лучших).; ISBN 978-5-17-122183-6 : 2000 экз."
+ )
+
+ parsed = rsl_rkp.parse_bibliographic_description(text)
+
+ assert parsed["title"] == "Никто не заплачет"
+ assert parsed["author"] == "Полина Дашкова"
+ assert parsed["publisher"] == "АСТ"
+ assert parsed["year"] == "2020"
+ assert parsed["series"] == "Полина Дашкова - лучшая среди лучших"
+
+ def test_multi_author_split_by_comma(self):
+ text = (
+ "Nudge. Архитектура выбора : Архитектура выбора : "
+ "как улучшить наши решения о здоровье : [16+] / "
+ "Ричард Талер, Касс Санстейн ; пер. с англ. Е. Петровой. - "
+ "Москва : Манн, Иванов и Фербер, 2017. - 240 с."
+ )
+
+ parsed = rsl_rkp.parse_bibliographic_description(text)
+
+ assert parsed["title"].startswith("Nudge")
+ assert parsed["author"] == "Ричард Талер, Касс Санстейн"
+ assert parsed["publisher"] == "Манн, Иванов и Фербер"
+ assert parsed["year"] == "2017"
+
+ def test_no_author_block_returns_empty_author(self):
+ text = "Сборник стихов. - Москва : Эксмо, 2019. - 256 с."
+
+ parsed = rsl_rkp.parse_bibliographic_description(text)
+
+ assert parsed["title"] == "Сборник стихов"
+ assert parsed["author"] == ""
+ assert parsed["publisher"] == "Эксмо"
+ assert parsed["year"] == "2019"
+
+ def test_no_year_yields_empty_year(self):
+ text = "Какая-то книга / Иван Иванов. - Москва : Альпина."
+
+ parsed = rsl_rkp.parse_bibliographic_description(text)
+
+ assert parsed["title"] == "Какая-то книга"
+ assert parsed["author"] == "Иван Иванов"
+ assert parsed["publisher"] == "Альпина"
+ assert parsed["year"] == ""
+
+ def test_strips_material_marker_and_age_rating(self):
+ text = "Эра млекопитающих [Текст] : [12+] / Стив Брусатти. - Москва : МИФ, 2024."
+
+ parsed = rsl_rkp.parse_bibliographic_description(text)
+
+ assert parsed["title"] == "Эра млекопитающих"
+ assert parsed["author"] == "Стив Брусатти"
+ assert parsed["publisher"] == "МИФ"
+ assert parsed["year"] == "2024"
+
+
+class TestRslContentParser:
+ """Извлечение карточек из поля ``content`` ответа ajax-search."""
+
+ def test_found_fixture_returns_one_card(self):
+ fixture = json.loads((FIXTURES_DIR / "rsl_search_found.json").read_text())
+
+ cards = rsl_rkp.parse_search_content(fixture["content"])
+
+ assert len(cards) == 1
+ card = cards[0]
+ assert card["record_id"] == "01010444981"
+ assert "Никто не заплачет" in card["main_info"]
+ assert "Дашкова" in card["author_info"]
+ # Тема должна попасть в topics
+ assert "Художественная" in card["topic"]
+
+ def test_not_found_fixture_returns_empty_list(self):
+ fixture = json.loads((FIXTURES_DIR / "rsl_search_notfound.json").read_text())
+
+ cards = rsl_rkp.parse_search_content(fixture["content"])
+
+ assert cards == []
+
+
+class TestFetchFromRslRkp:
+ """Интеграция fetch_from_rsl_rkp через httpx.MockTransport."""
+
+ @pytest.mark.asyncio
+ async def test_disabled_via_env_returns_none_without_network(self, monkeypatch):
+ monkeypatch.setenv("RSL_RKP_ENABLED", "0")
+
+ def fail(_request: httpx.Request) -> httpx.Response:
+ msg = "сеть не должна вызываться при выключенном RSL_RKP"
+ raise AssertionError(msg)
+
+ transport = httpx.MockTransport(fail)
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client)
+
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_happy_path_returns_book_record(self, monkeypatch):
+ monkeypatch.delenv("RSL_RKP_ENABLED", raising=False)
+ fixture = json.loads((FIXTURES_DIR / "rsl_search_found.json").read_text())
+ bootstrap_html = ''
+ seen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ if request.method == "GET":
+ return httpx.Response(200, text=bootstrap_html)
+ assert request.method == "POST"
+ assert "ajax-search" in str(request.url)
+ return httpx.Response(200, json=fixture)
+
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client)
+
+ assert result is not None
+ assert "Никто не заплачет" in result.title
+ assert "Дашкова" in result.author
+ assert result.publisher == "АСТ"
+ assert result.isbn == "9785171221836"
+ assert result.link.endswith("/ru/record/01010444981")
+ methods = [r.method for r in seen]
+ assert methods.count("POST") == 1, "ровно один POST к ajax-search"
+ assert methods.count("GET") >= 1, "минимум один bootstrap GET"
+ # CSRF из bootstrap должен идти в POST
+ post_req = next(r for r in seen if r.method == "POST")
+ assert post_req.headers.get("X-CSRF-Token") == "TEST_CSRF"
+ body = post_req.read().decode()
+ assert "SearchFilterForm%5Bsearch%5D=9785171221836" in body
+
+ @pytest.mark.asyncio
+ async def test_total_hits_zero_returns_none(self, monkeypatch):
+ monkeypatch.delenv("RSL_RKP_ENABLED", raising=False)
+ fixture = json.loads((FIXTURES_DIR / "rsl_search_notfound.json").read_text())
+ bootstrap_html = ''
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "GET":
+ return httpx.Response(200, text=bootstrap_html)
+ return httpx.Response(200, json=fixture)
+
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await rsl_rkp.fetch_from_rsl_rkp("9785961433234", client)
+
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_network_error_returns_none(self, monkeypatch):
+ monkeypatch.delenv("RSL_RKP_ENABLED", raising=False)
+
+ def raise_error(_request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("boom")
+
+ transport = httpx.MockTransport(raise_error)
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client)
+
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_missing_csrf_returns_none(self, monkeypatch):
+ monkeypatch.delenv("RSL_RKP_ENABLED", raising=False)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "GET":
+ return httpx.Response(200, text="")
+ return httpx.Response(200, json={"TotalHits": 0, "content": ""})
+
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(transport=transport) as client:
+ result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client)
+
+ assert result is None