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..92d96ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,15 @@ 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` и не замедляет гонку.
+
Notes:
- If `venv/` is absent, create it instead of assuming it exists.
diff --git a/MEMORY.md b/MEMORY.md
index 6154f84..f6c3d02 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -22,7 +22,12 @@
- Бот сам создает `library.db` и `users.db`, если файлов нет.
- `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`.
- `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта.
-- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> DuckDuckGo`.
+- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> Yandex (опциональный) -> DuckDuckGo`. Каждой найденной карточке `lookup` проставляет `BookRecord.confidence` через `home_library/providers/_score.score_book_record`: верифицированные каталоги (РГБ, Labirint, Piter, MIF) — высокий вес, SERP-fallback (DDG, Yandex, Playwright site search) — средний/низкий. Если у возвращённой карточки `confidence < CONFIDENCE_THRESHOLD` (по умолчанию 0.6 из `home_library/config.py`), `_present_book_for_isbn` не показывает preview, а ставит `PendingScanDraft` с предзаполненными полями и просьбой проверить вручную.
+- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `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 +48,15 @@
- Для тестов SQLite путь к БД подменяется через `monkeypatch` в `tests/conftest.py`.
- Для новых фич придерживаться test-first подхода: сначала тесты, потом реализация.
+## Git Workflow Notes
+
+- Большие наборы изменений сначала раскладывать по отдельным тематическим веткам, а не копить в одной длинной feature-ветке.
+- По возможности держать одну пользовательскую или техническую задачу в одной ветке и в одном PR.
+- Коммиты делать атомарными: один логический шаг, в идеале одно изменение в одном файле или в тесно связанном наборе файлов.
+- Формат commit message: `feature(): <короткое описание>`, `fix(): <короткое описание>`, `refactor(): <короткое описание>`.
+- В body коммита и описании PR писать понятное человеку объяснение: что изменилось, какую проблему это решает и зачем выбран именно такой вариант.
+- Если новая ветка зависит от другой тематической ветки, явно сохранять эту зависимость в базе ветки, а не прятать ее в конфликтах при переносе.
+
## Pending Architectural Work
- Хэндлеры разбиты на `commands.py`, `callbacks.py`, `access.py`, `_helpers.py` и `main.py`.
diff --git a/README.md b/README.md
index e8480e0..0264c53 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 во внешних источниках: Лабиринт, 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,52 @@ 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.
+
+## Фото Оборота Титульного Листа
+
+Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`.
+
+Подходящее фото обычно содержит выходные сведения, например:
+
+```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 +225,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..9e2be09 100644
--- a/home_library/config.py
+++ b/home_library/config.py
@@ -31,6 +31,7 @@
SEARCH_LIMIT = 15
ISBN13_LENGTH = 13
ISBN10_LENGTH = 10
+CONFIDENCE_THRESHOLD = 0.6
@dataclass(frozen=True)
diff --git a/home_library/domain/models.py b/home_library/domain/models.py
index 2fa9ec2..7d17a9c 100644
--- a/home_library/domain/models.py
+++ b/home_library/domain/models.py
@@ -46,6 +46,7 @@ class BookRecord(SqliteRowModel):
isbn: str = ""
id: int | None = None
user_data: list[UserBookDataRecord] = Field(default_factory=list)
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
@dataclass(slots=True)
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..7db73b9 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,56 @@ 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)
+
+ if book_data.confidence < config.CONFIDENCE_THRESHOLD:
+ logger.info(
+ "ISBN %s: низкий confidence %.2f → scan-draft на проверку (title=%r)",
+ isbn,
+ book_data.confidence,
+ book_data.title,
+ )
+ await _route_low_confidence_to_scan_draft(message, user_data, book_data, isbn)
+ return
+
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}",
- ),
- ],
- ],
+
+async def _route_low_confidence_to_scan_draft(
+ message: Message,
+ user_data: dict[str, object],
+ book_data: BookRecord,
+ isbn: str,
+) -> None:
+ """Создаёт scan-draft с предзаполнёнными полями и просит пользователя проверить.
+
+ Низкий confidence означает, что провайдер ответил, но у нас мало
+ оснований доверять (например, это DDG-парсинг без автора). Вместо
+ того чтобы сразу предлагать «добавить в каталог», ставим черновик
+ и даём пользователю отредактировать поля или подтвердить.
+ """
+ draft = PendingScanDraft(
+ token=_make_scan_draft_token(isbn=isbn, author=book_data.author, title=book_data.title),
+ isbn=isbn,
+ author=book_data.author,
+ title=book_data.title,
)
- preview_book = book_data.model_copy()
+ user_data[PENDING_SCAN_DRAFT_KEY] = draft
await message.reply_text(
- f"Найдена книга:\n\n{format_book(preview_book)}\n\nДобавить в каталог?",
+ "Нашёл данные, но не уверен в них. Проверь и при необходимости уточни поля:\n\n"
+ f"{format_scan_draft(draft, from_verso=False)}",
parse_mode="HTML",
- disable_web_page_preview=True,
- reply_markup=keyboard,
+ reply_markup=scan_draft_keyboard(draft.token, from_verso=False),
)
-async def handle_add_book_callback(
+async def handle_add_book_callback( # noqa: PLR0911
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
@@ -258,15 +848,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 +895,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/_score.py b/home_library/providers/_score.py
new file mode 100644
index 0000000..de4b339
--- /dev/null
+++ b/home_library/providers/_score.py
@@ -0,0 +1,78 @@
+"""Оценка достоверности (confidence) BookRecord по источнику и заполненности.
+
+Каждый провайдер имеет свой «уровень доверия» (verified-каталог vs.
+SERP-парсинг), а каждая запись — сколько ключевых полей удалось
+заполнить. Произведение этих двух факторов даёт итоговый score
+от 0.0 до 1.0, который потом используется в ``callbacks`` для
+маршрутизации: высокая уверенность → preview карточки, низкая →
+scan-draft с просьбой проверить вручную.
+
+Все источники, не указанные в реестре, трактуются как SERP-уровень
+(самый низкий) — это безопасный fallback.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from home_library.domain.models import BookRecord
+
+
+_SOURCE_WEIGHTS: dict[str, float] = {
+ "РГБ": 0.95,
+ "RSL": 0.95,
+ "Лабиринт": 0.9,
+ "Labirint": 0.9,
+ "Piter": 0.9,
+ "МИФ": 0.9,
+ "Альпина": 0.9,
+ "Эксмо": 0.9,
+ "АСТ": 0.9,
+ "Open Library": 0.85,
+ "Google Books": 0.85,
+ "OpenLibrary": 0.85,
+ "Flibusta": 0.7,
+ "Яндекс": 0.6,
+ "Yandex": 0.6,
+ "LiveLib": 0.6,
+ "MyBook": 0.6,
+ "Playwright site search": 0.6,
+ "DuckDuckGo": 0.5,
+ "DDG": 0.5,
+}
+
+_FIELD_WEIGHTS: tuple[tuple[str, float], ...] = (
+ ("title", 0.5),
+ ("author", 0.3),
+ ("publisher", 0.1),
+ ("topics", 0.1),
+)
+
+
+def score_book_record(record: BookRecord, *, source: str) -> float:
+ """Считает confidence для записи по источнику и заполненности полей.
+
+ Args:
+ record: Найденная карточка книги.
+ source: Имя провайдера (как в ``primary_providers`` в
+ ``lookup.fetch_book_by_isbn``). Неизвестный источник
+ трактуется как SERP-уровень.
+
+ Returns:
+ ``float`` в диапазоне ``[0.0, 1.0]``. ``1.0`` оставлен как
+ потенциальный «верифицированный человеком» уровень и не
+ выдаётся ни одним провайдером автоматически.
+ """
+ source_weight = _SOURCE_WEIGHTS.get(source, 0.5)
+
+ fill_score = 0.0
+ for field_name, field_weight in _FIELD_WEIGHTS:
+ if getattr(record, field_name, ""):
+ fill_score += field_weight
+
+ score = source_weight * fill_score
+ return max(0.0, min(1.0, score))
+
+
+__all__ = ["score_book_record"]
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..7d497da 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
@@ -10,14 +11,31 @@
import httpx
from home_library.domain.models import BookRecord
+from home_library.providers._score import score_book_record
from home_library.providers.ddg import fetch_from_ddg
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.yandex import fetch_from_yandex
logger = logging.getLogger(__name__)
+def _with_confidence(record: BookRecord, *, source: str) -> BookRecord:
+ """Возвращает копию ``record`` с проставленным confidence.
+
+ Если провайдер сам выставил ``confidence`` (отличный от дефолта 1.0)
+ — мы его уважаем. Иначе считаем по реестру источников.
+ """
+ if record.confidence != 1.0:
+ return record
+ score = score_book_record(record, source=source)
+ return record.model_copy(update={"confidence": score})
+
+
async def fetch_book_by_isbn(isbn: str) -> BookRecord | None:
"""Ищет книгу по ISBN, параллельно опрашивая основные провайдеры.
@@ -41,10 +59,13 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None:
logger.info("Начинаю поиск книги по ISBN %s", isbn)
# Порядок приоритета: результат первого по индексу побеждает.
+ # Яндекс подключён опционально через env YANDEX_ENABLED=1 — без флага
+ # провайдер мгновенно возвращает None и не замедляет гонку.
primary_providers = [
("Лабиринт", fetch_from_labirint),
("Piter", fetch_from_piter),
("Google Books", fetch_from_google_books),
+ ("Яндекс", fetch_from_yandex),
]
async with httpx.AsyncClient(timeout=10) as client:
@@ -61,14 +82,20 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None:
continue
if result is not None:
logger.info("ISBN %s найден через %s: %r", isbn, name, result.title)
- return result
+ return _with_confidence(result, source=name)
+
+ 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 _with_confidence(book, source="Playwright site search")
# 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)
- return book
+ return _with_confidence(book, source="DuckDuckGo")
logger.info("ISBN %s не найден ни в одном из провайдеров", isbn)
return None
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/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/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..17c06e8 100644
--- a/tests/test_home_library_e2e.py
+++ b/tests/test_home_library_e2e.py
@@ -5,12 +5,12 @@
from home_library import config
from home_library.domain.models import BookRecord, EditState
from home_library.interfaces.telegram import handlers
+from home_library.providers.title_page import ParsedTitlePage
from home_library.storage import sqlite as db
from home_library.storage import users_sqlite as access_db
from tests.conftest import make_book
from tests.helpers.bot_driver import BotDriver
-PHOTO_FLOW_REPLY_COUNT = 2
OWNER_TELEGRAM_USER_ID = 900
ANNA_TELEGRAM_USER_ID = 901
ANNA_NEW_TELEGRAM_USER_ID = 902
@@ -70,18 +70,61 @@ async def test_text_search_reports_missing_author_in_demo_db(test_db) -> None:
@pytest.mark.asyncio
async def test_handle_photo_reports_unreadable_barcode(test_db, monkeypatch) -> None:
- """Проверяет отрицательную ветку распознавания штрихкода."""
+ """Даже без штрихкода бот показывает черновик для ручной проверки."""
monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: None)
driver = BotDriver()
await driver.send_photo()
- assert driver.last_reply_text().startswith("Не удалось распознать штрихкод")
+ assert "Что удалось распознать:" in driver.last_reply_text()
+ assert "ISBN: не найден" in driver.last_reply_text()
+ assert driver.user_data.get("pending_scan_draft") is not None
+
+
+@pytest.mark.asyncio
+async def test_scan_draft_keyboard_separates_confirm_lookup_and_cancel_buttons(test_db, monkeypatch) -> None:
+ """Кнопки подтверждения, поиска и отмены должны быть в отдельных строках."""
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773")
+ driver = BotDriver()
+
+ await driver.send_photo()
+
+ reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ assert reply_markup.inline_keyboard[3][0].text == "✅ Всё верно, добавить в библиотеку"
+ assert reply_markup.inline_keyboard[4][0].text == "🔎 Искать информацию"
+ assert reply_markup.inline_keyboard[5][0].text == "❌ Отмена"
+
+
+@pytest.mark.asyncio
+async def test_scan_draft_confirm_adds_book_without_lookup(test_db, monkeypatch) -> None:
+ """Подтверждение scan draft сразу добавляет книгу и не запускает внешний поиск."""
+ calls = 0
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
+ nonlocal calls
+ calls += 1
+ return make_book(title="Чистая архитектура", author="Роберт Мартин", isbn="9785446110773")
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ driver = BotDriver()
+
+ await driver.send_photo()
+ confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data
+
+ callback = await driver.click(confirm_callback)
+
+ assert callback.answered is True
+ assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"]
+ assert "📦 ISBN: 9785446110773" in callback.edits[0]["text"]
+ assert calls == 0
+ assert driver.user_data.get("pending_book") is None
+ assert db.find_existing_book("", "", isbn="9785446110773") is not None
@pytest.mark.asyncio
async def test_handle_photo_finds_existing_book_by_barcode_in_db(test_db, monkeypatch) -> None:
- """Проверяет ветку ISBN, который уже есть в каталоге."""
+ """Кнопка поиска из черновика находит существующую книгу по ISBN."""
async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
return make_book(
@@ -96,17 +139,21 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
driver = BotDriver()
await driver.send_photo()
+ reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ lookup_callback = reply_markup.inline_keyboard[4][0].callback_data
+
+ callback = await driver.click(lookup_callback)
- assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT
- assert "📦 Найден ISBN" in driver.reply(0)["text"]
- assert "Книга уже есть в каталоге" in driver.reply(1)["text"]
- assert "Взрослые дети эмоционально незрелых родителей" in driver.reply(1)["text"]
+ assert callback.answered is True
+ assert callback.edits[0]["text"] == "Ищу информацию о книге..."
+ assert "Книга уже есть в каталоге" in driver.last_reply_text()
+ assert "Взрослые дети эмоционально незрелых родителей" in driver.last_reply_text()
assert "pending_book" not in driver.user_data
@pytest.mark.asyncio
async def test_handle_photo_adds_new_book_by_barcode_after_confirmation(test_db, monkeypatch) -> None:
- """Проверяет полный flow добавления новой книги по ISBN."""
+ """Проверяет полный flow: черновик -> поиск по ISBN -> preview -> добавление."""
async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
return make_book(
@@ -123,14 +170,17 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
driver = BotDriver()
await driver.send_photo()
+ scan_reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ lookup_callback = scan_reply_markup.inline_keyboard[4][0].callback_data
+
+ await driver.click(lookup_callback)
- assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT
- assert "Найдена книга" in driver.reply(1)["text"]
+ assert "Найдена книга" in driver.last_reply_text()
pending_book = driver.user_data.get("pending_book")
assert isinstance(pending_book, BookRecord)
assert pending_book.title == "Чистая архитектура"
- reply_markup = driver.reply(1)["kwargs"]["reply_markup"]
- confirm_callback = reply_markup.inline_keyboard[0][0].callback_data
+ reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ confirm_callback = reply_markup.inline_keyboard[3][0].callback_data
callback = await driver.click(confirm_callback)
@@ -141,8 +191,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
@pytest.mark.asyncio
-async def test_handle_photo_rejects_stale_add_book_confirmation(test_db, monkeypatch) -> None:
- """Старая кнопка подтверждения не должна добавлять новую pending-книгу."""
+async def test_handle_photo_rejects_stale_scan_draft_confirmation(test_db, monkeypatch) -> None:
+ """Старая кнопка поиска из черновика не должна запускать поиск по новым данным."""
books_by_isbn = {
"9785446110773": make_book(
title="Чистая архитектура",
@@ -169,30 +219,31 @@ async def fake_fetch_book_by_isbn(isbn: str) -> BookRecord:
driver = BotDriver()
await driver.send_photo()
- first_reply_markup = driver.reply(1)["kwargs"]["reply_markup"]
- stale_confirm_callback = first_reply_markup.inline_keyboard[0][0].callback_data
+ first_reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ stale_lookup_callback = first_reply_markup.inline_keyboard[4][0].callback_data
await driver.send_photo()
- second_reply_markup = driver.reply(1)["kwargs"]["reply_markup"]
- current_confirm_callback = second_reply_markup.inline_keyboard[0][0].callback_data
+ second_reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ current_lookup_callback = second_reply_markup.inline_keyboard[4][0].callback_data
- stale_callback = await driver.click(stale_confirm_callback)
+ stale_callback = await driver.click(stale_lookup_callback)
assert stale_callback.answered is True
- assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново."
+ assert stale_callback.edits[0]["text"] == "Данные распознавания устарели. Отправь фото заново."
assert db.find_existing_book("Чистая архитектура", "Роберт Мартин", isbn="9785446110773") is None
assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None
- current_callback = await driver.click(current_confirm_callback)
+ current_callback = await driver.click(current_lookup_callback)
assert current_callback.answered is True
- assert "✅ Книга добавлена в каталог" in current_callback.edits[0]["text"]
- assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is not None
+ assert current_callback.edits[0]["text"] == "Ищу информацию о книге..."
+ assert "Найдена книга" in driver.last_reply_text()
+ assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None
@pytest.mark.asyncio
async def test_handle_photo_reports_missing_book_outside_db(test_db, monkeypatch) -> None:
- """Проверяет отрицательную ветку ISBN вне каталога и вне провайдеров."""
+ """По кнопке поиска из черновика бот уходит в fallback с ISBN hint."""
async def fake_fetch_book_by_isbn(_isbn: str) -> None:
return None
@@ -202,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
- assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT
- assert driver.reply(1)["text"] == (
- "Книга с ISBN 9789999999999 не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск."
+ await driver.click(lookup_callback)
+
+ assert driver.last_reply_text() == (
+ "Книга с ISBN 9789999999999 не найдена.\n"
+ "Напиши название, автора или часть — попробую с подсказкой (или /cancel)."
)
- assert driver.user_data == {}
+ 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 "Название: Книготерапия" in driver.last_reply_text()
+ assert "Всё верно?" in driver.last_reply_text()
+
+
+@pytest.mark.asyncio
+async def test_scan_draft_confirm_after_verso_edit_adds_book_directly(test_db, monkeypatch) -> None:
+ """После OCR с оборота и ручной правки автора подтверждение сразу добавляет книгу."""
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "")
+ driver = BotDriver()
+
+ await driver.send_photo()
+ verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data
+ await driver.click(verso_callback)
+
+ monkeypatch.setattr(
+ handlers.callbacks,
+ "parse_title_page_image",
+ lambda _image_bytes: ParsedTitlePage(
+ isbn="9785042195730",
+ author="Челомова, Надежда Алексеевна",
+ title="Книготерапия : научно доказанный метод самопомощи",
+ raw_text="raw",
+ ),
+ )
+ await driver.send_photo(payload=b"verso")
+
+ edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data
+ await driver.click(edit_author_callback)
+ await driver.send_text("Челомова Надежда Алексеевна")
+
+ confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data
+ callback = await driver.click(confirm_callback)
+
+ assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"]
+ assert "Челомова Надежда Алексеевна" in callback.edits[0]["text"]
+ assert "Добавить в каталог?" not in callback.edits[0]["text"]
+ assert driver.user_data.get("pending_book") is None
+
+
+@pytest.mark.asyncio
+async def test_scan_draft_blocks_continue_while_field_edit_is_active(test_db, monkeypatch) -> None:
+ """Во время ручной правки scan draft кнопка поиска не должна срабатывать."""
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785042195730")
+ driver = BotDriver()
+
+ await driver.send_photo()
+ reply_markup = driver.reply()["kwargs"]["reply_markup"]
+ lookup_callback = reply_markup.inline_keyboard[4][0].callback_data
+ edit_author_callback = reply_markup.inline_keyboard[0][1].callback_data
+
+ await driver.click(edit_author_callback)
+ callback = await driver.click(lookup_callback)
+
+ assert callback.answer_kwargs == {
+ "text": "Сначала заверши редактирование поля.",
+ "show_alert": True,
+ }
+ assert callback.edits == []
+
+
+@pytest.mark.asyncio
+async def test_pending_book_preview_supports_editing_before_add(test_db, monkeypatch) -> None:
+ """Перед добавлением книги можно исправить ISBN, автора и название."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
+ return make_book(
+ title="System Design. Подготовка к сложному интервью",
+ author="Сью Алекс",
+ publisher="Питер",
+ isbn="9785446118168",
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ driver = BotDriver()
+
+ await driver.send_photo()
+ lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data
+ await driver.click(lookup_callback)
+
+ preview_markup = driver.reply()["kwargs"]["reply_markup"]
+ edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data
+ await driver.click(edit_author_callback)
+ assert driver.last_edit_text() == "Сью Алекс"
+ assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)"
+
+ await driver.send_text("Алекс Сью")
+ assert "✍️ Алекс Сью" in driver.last_reply_text()
+ updated_preview_markup = driver.reply()["kwargs"]["reply_markup"]
+
+ edit_isbn_callback = updated_preview_markup.inline_keyboard[0][0].callback_data
+ stale_confirm_callback = preview_markup.inline_keyboard[3][0].callback_data
+ await driver.click(edit_isbn_callback)
+ assert driver.last_edit_text() == "9785446118168"
+ assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)"
+ await driver.send_text("978-5-4461-1816-0")
+ assert "📦 ISBN: 9785446118160" in driver.last_reply_text()
+
+ updated_preview_markup = driver.reply()["kwargs"]["reply_markup"]
+ edit_title_callback = updated_preview_markup.inline_keyboard[1][0].callback_data
+ await driver.click(edit_title_callback)
+ assert driver.last_edit_text() == "System Design. Подготовка к сложному интервью"
+ assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)"
+ await driver.send_text("System Design")
+ assert "📖 System Design" in driver.last_reply_text()
+
+ stale_callback = await driver.click(stale_confirm_callback)
+ assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново."
+
+ confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data
+ callback = await driver.click(confirm_callback)
+
+ assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"]
+ assert "System Design" in callback.edits[0]["text"]
+ assert "Алекс Сью" in callback.edits[0]["text"]
+ assert "9785446118160" in callback.edits[0]["text"]
+
+
+@pytest.mark.asyncio
+async def test_pending_book_lookup_preserves_manual_fields(test_db, monkeypatch) -> None:
+ """Поиск из preview дополняет книгу, но не затирает ручные правки."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
+ return make_book(
+ title="System Design. Подготовка к сложному интервью",
+ author="Сью Алекс",
+ publisher="Питер",
+ isbn="9785446118168",
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ driver = BotDriver()
+
+ await driver.send_photo()
+ lookup_scan_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data
+ await driver.click(lookup_scan_callback)
+
+ preview_markup = driver.reply()["kwargs"]["reply_markup"]
+ edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data
+ await driver.click(edit_author_callback)
+ await driver.send_text("Алекс Сью")
+
+ lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data
+ callback = await driver.click(lookup_callback)
+
+ assert callback.edits[0]["text"] == "Ищу информацию о книге..."
+ assert "📖 System Design. Подготовка к сложному интервью" in driver.last_reply_text()
+ assert "✍️ Алекс Сью" in driver.last_reply_text()
+ assert "🏢 Питер" in driver.last_reply_text()
+ pending_book = driver.user_data.get("pending_book")
+ assert isinstance(pending_book, BookRecord)
+ assert pending_book.author == "Алекс Сью"
+ assert pending_book.publisher == "Питер"
+
+
+@pytest.mark.asyncio
+async def test_pending_book_preview_blocks_add_while_field_edit_is_active(test_db, monkeypatch) -> None:
+ """Во время ручной правки preview книги кнопка добавления не должна срабатывать."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
+ return make_book(
+ title="System Design. Подготовка к сложному интервью",
+ author="Сью Алекс",
+ publisher="Питер",
+ isbn="9785446118168",
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ driver = BotDriver()
+
+ await driver.send_photo()
+ lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data
+ await driver.click(lookup_callback)
+
+ preview_markup = driver.reply()["kwargs"]["reply_markup"]
+ confirm_callback = preview_markup.inline_keyboard[3][0].callback_data
+ edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data
+
+ await driver.click(edit_author_callback)
+ callback = await driver.click(confirm_callback)
+
+ assert callback.answer_kwargs == {
+ "text": "Сначала заверши редактирование поля.",
+ "show_alert": True,
+ }
+ assert callback.edits == []
+
+
+@pytest.mark.asyncio
+async def test_cancel_clears_pending_preview_book_edit(test_db, monkeypatch) -> None:
+ """/cancel сбрасывает режим ручной правки книги перед добавлением."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord:
+ return make_book(
+ title="Чистая архитектура",
+ author="Роберт Мартин",
+ publisher="Питер",
+ isbn="9785446110773",
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773")
+ monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn)
+ driver = BotDriver()
+
+ await driver.send_photo()
+ lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data
+ await driver.click(lookup_callback)
+
+ edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data
+ await driver.click(edit_author_callback)
+ assert driver.last_edit_text() == "Роберт Мартин"
+ assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)"
+
+ await driver.cancel()
+
+ assert driver.user_data.get("pending_book_editing") is None
+ assert driver.last_reply_text() == "Редактирование книги перед добавлением отменено."
+
+
+@pytest.mark.asyncio
+async def test_cancel_clears_pending_scan_draft(test_db, monkeypatch) -> None:
+ """/cancel сбрасывает ожидание проверки распознанных данных."""
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168")
+ driver = BotDriver()
+
+ await driver.send_photo()
+ assert driver.user_data.get("pending_scan_draft") is not None
+
+ await driver.cancel()
+
+ assert driver.user_data.get("pending_scan_draft") is None
+ assert driver.last_reply_text() == "Проверка распознанных данных отменена."
@pytest.mark.asyncio
@@ -408,3 +876,66 @@ async def test_non_admin_cannot_add_library_member_or_export(test_db, monkeypatc
await stranger.command("export", "json")
assert stranger.last_reply_text() == "Извини, у тебя нет доступа к этому боту."
+
+
+# ---------------------------------------------------------------------------
+# Low-confidence routing → scan-draft
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_low_confidence_lookup_routes_to_scan_draft(test_db, monkeypatch) -> None:
+ """Если провайдер вернул низкий confidence — бот не показывает preview, а просит подтверждение."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str):
+ return BookRecord(
+ title="Подозрительный заголовок",
+ author="",
+ isbn="9785999999999",
+ confidence=0.3,
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785999999999")
+ 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[4][0].callback_data
+ await driver.click(confirm_callback)
+
+ last = driver.last_message
+ assert last is not None
+ text_chain = " ".join(str(r["text"]) for r in last.replies)
+ assert "Найдена книга" not in text_chain, (
+ f"low-confidence результат не должен показываться как pending preview; replies: {text_chain[:300]}"
+ )
+ assert "проверь" in text_chain.lower() or "уточни" in text_chain.lower(), (
+ f"ожидаем приглашение пользователю проверить данные: {text_chain[:300]}"
+ )
+
+
+@pytest.mark.asyncio
+async def test_high_confidence_lookup_shows_pending_preview(test_db, monkeypatch) -> None:
+ """При confidence >= порога поведение прежнее — preview книги."""
+
+ async def fake_fetch_book_by_isbn(_isbn: str):
+ return BookRecord(
+ title="Чистая архитектура",
+ author="Роберт Мартин",
+ publisher="Питер",
+ isbn="9785999999999",
+ confidence=0.92,
+ )
+
+ monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785999999999")
+ 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[4][0].callback_data
+ await driver.click(confirm_callback)
+
+ last = driver.last_message
+ assert last is not None
+ text_chain = " ".join(str(r["text"]) for r in last.replies)
+ assert "Найдена книга" in text_chain or "Добавить в каталог" in text_chain
diff --git a/tests/test_home_library_models.py b/tests/test_home_library_models.py
index 263b649..eeaf8cf 100644
--- a/tests/test_home_library_models.py
+++ b/tests/test_home_library_models.py
@@ -2,6 +2,9 @@
import sqlite3
+import pytest
+from pydantic import ValidationError
+
from home_library.domain.access_models import UserTelegramLinkRecord
from home_library.domain.models import (
BookRecord,
@@ -10,6 +13,8 @@
UserRecord,
)
+CONFIDENCE_SAMPLE = 0.45
+
LINK_TELEGRAM_USER_ID = 123
@@ -95,3 +100,32 @@ def test_piter_product_parses_characteristics() -> None:
},
)
assert product.characteristics[0].title == "Автор"
+
+
+# ---------------------------------------------------------------------------
+# BookRecord confidence
+# ---------------------------------------------------------------------------
+
+
+def test_book_record_confidence_defaults_to_one() -> None:
+ """Без явного указания ``confidence`` считаем, что данные точные."""
+ book = BookRecord(title="Test", author="A", isbn="123")
+ assert book.confidence == 1.0
+
+
+def test_book_record_confidence_accepts_value_in_range() -> None:
+ """Confidence хранит float в диапазоне 0..1."""
+ book = BookRecord(title="Test", author="A", isbn="123", confidence=CONFIDENCE_SAMPLE)
+ assert book.confidence == CONFIDENCE_SAMPLE
+
+
+def test_book_record_confidence_rejects_value_above_one() -> None:
+ """Confidence выше 1.0 — невалидное значение."""
+ with pytest.raises(ValidationError):
+ BookRecord(title="T", confidence=1.5)
+
+
+def test_book_record_confidence_rejects_negative_value() -> None:
+ """Confidence ниже 0.0 — невалидное значение."""
+ with pytest.raises(ValidationError):
+ BookRecord(title="T", confidence=-0.1)
diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py
index 7117348..ac3c232 100644
--- a/tests/test_home_library_providers.py
+++ b/tests/test_home_library_providers.py
@@ -1,10 +1,34 @@
"""Tests for provider helpers."""
+import builtins as _builtins
+import io
+import shutil
+from pathlib import Path
+
import httpx
import pytest
+from PIL import Image
from home_library.domain.models import BookRecord
-from home_library.providers import barcode, ddg, google_books, labirint, lookup, piter
+from home_library.providers import (
+ _score,
+ _serp_parser,
+ barcode,
+ ddg,
+ google_books,
+ labirint,
+ lookup,
+ piter,
+ playwright_site_search,
+ title_page,
+ yandex,
+)
+
+FIXTURES_DIR = Path(__file__).parent / "fixtures"
+
+OTSU_THRESHOLD_MIN = 50
+OTSU_THRESHOLD_MAX = 200
+PREPROCESS_MIN_VARIANTS = 5
# ---------------------------------------------------------------------------
# Barcode
@@ -19,6 +43,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 +424,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: Простые подсказки для умных родителей"
+ )
-class TestExtractDdgBook:
+ 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 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 +488,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 +614,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 +663,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 +1056,78 @@ 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"
+
+
+# ---------------------------------------------------------------------------
+# Confidence scoring
+# ---------------------------------------------------------------------------
+
+
+_HIGH_CONFIDENCE_FLOOR = 0.85
+_SERP_CONFIDENCE_CEIL = 0.7
+_SERP_CONFIDENCE_FLOOR = 0.3
+_TITLE_ONLY_CONFIDENCE_CEIL = 0.5
+
+
+class TestScoreBookRecord:
+ def test_full_record_from_authoritative_source_scores_high(self):
+ record = BookRecord(
+ title="Чистый код",
+ author="Роберт Мартин",
+ publisher="Питер",
+ topics="Программирование",
+ isbn="9785446119547",
+ link="https://labirint.ru/books/123/",
+ )
+ score = _score.score_book_record(record, source="Лабиринт")
+ assert score >= _HIGH_CONFIDENCE_FLOOR
+
+ def test_partial_record_from_serp_scores_lower(self):
+ record = BookRecord(
+ title="Какая-то книга",
+ author="Кто-то",
+ isbn="9785446119547",
+ )
+ score = _score.score_book_record(record, source="DuckDuckGo")
+ assert score < _SERP_CONFIDENCE_CEIL
+ assert score >= _SERP_CONFIDENCE_FLOOR
+
+ def test_only_title_returns_low_score(self):
+ record = BookRecord(title="Только название", isbn="123")
+ score = _score.score_book_record(record, source="DuckDuckGo")
+ assert score < _TITLE_ONLY_CONFIDENCE_CEIL
+
+ def test_unknown_source_treated_as_serp_grade(self):
+ """Неизвестный источник = безопасный fallback к среднему уровню."""
+ record = BookRecord(
+ title="X",
+ author="A",
+ publisher="P",
+ topics="T",
+ isbn="1",
+ )
+ score_known = _score.score_book_record(record, source="Лабиринт")
+ score_unknown = _score.score_book_record(record, source="Random")
+ assert score_unknown <= score_known