diff --git a/.gitignore b/.gitignore index 2c70ff3..ee77577 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ users.db *.log stdout.txt stderr.txt +.bot.pid .coverage coverage.xml htmlcov/ diff --git a/AGENTS.md b/AGENTS.md index 317fa09..0a2bd5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,24 @@ export LIBRARY_BOT_TOKEN="" export LIBRARY_BOT_ADMIN_USERNAME="" python -m home_library ``` + +Optional Yandex provider via Playwright (для ISBN, не находящихся в основных каталогах): +```bash +python -m pip install -r requirements-dev.txt # включает playwright, playwright-stealth +playwright install chromium +export YANDEX_ENABLED=1 +``` +Без `YANDEX_ENABLED` провайдер Yandex мгновенно возвращает `None` и не замедляет гонку. + +Каталог РГБ (search.rsl.ru) опрашивается первым в основной гонке и +включён по умолчанию. Чтобы выключить (например, на нестабильной сети): + +```bash +export RSL_RKP_ENABLED=0 +``` + +С `RSL_RKP_ENABLED=0` провайдер не делает ни одного сетевого вызова. + Notes: - If `venv/` is absent, create it instead of assuming it exists. diff --git a/MEMORY.md b/MEMORY.md index 6154f84..9ebcb20 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -22,7 +22,13 @@ - Бот сам создает `library.db` и `users.db`, если файлов нет. - `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`. - `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта. -- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> DuckDuckGo`. +- Поиск книги по ISBN идет по цепочке `RSL/RKP (search.rsl.ru) -> Labirint -> Piter -> Google Books -> Yandex (опциональный) -> Playwright site search -> DuckDuckGo`. РГБ опрашивается первым: для префиксов `978-5-…` это даёт ~70% покрытия по реальной выборке без зависимости от Playwright/CAPTCHA. +- РГБ-провайдер включён по умолчанию; `RSL_RKP_ENABLED=0` мгновенно отключает его без сетевых вызовов. Для запроса нужен один bootstrap-`GET https://search.rsl.ru/` (отдаёт CSRF + cookies), затем `POST /site/ajax-search?language=ru` с `SearchFilterForm[search]=`. Ответ — JSON с `TotalHits` и HTML-фрагментом карточек в `content`. Парсер вытаскивает `data-id` и описание ГОСТ 7.1, превращая его в `BookRecord` (title/author/publisher/topics/link). +- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `PendingScanDraft` с полями `isbn`, `author`, `title`; пользователь может поправить их вручную или прислать фото оборота титульного листа. +- OCR оборота титульного листа вынесен в `home_library/providers/title_page.py`: используется `Pillow` для препроцессинга и системный `tesseract` (`rus+eng`) для распознавания текста; дальше срабатывают простые эвристики для `ISBN`, автора и названия. +- Yandex-провайдер активируется только при `YANDEX_ENABLED=1` и установленных `playwright`+`playwright-stealth` (нужен `playwright install chromium`), иначе тихо возвращает `None`. +- Общие паттерны чистки SERP-заголовков и извлечения автора вынесены в `home_library/providers/_serp_parser.py` и переиспользуются DDG и Yandex. +- Если ни один провайдер не нашёл книгу, бот сохраняет `PendingIsbnHint` в `user_data["pending_isbn_hint"]`, просит подсказку у пользователя и повторяет поиск через `fetch_from_ddg_with_context` (DDG с `q = "{isbn} {hint}"`). `/cancel` сбрасывает ожидание подсказки. - Бот умеет экспортировать каталог через `/export` в `json`, `xlsx`, `csv`, `yaml` и `db`; без аргумента показывает inline-кнопки выбора формата, но сам экспорт доступен только админу и не включает `users.db`. - У бота есть access control через `/add_access_to_library`: бот всегда работает только по whitelist. - Самый первый запуск требует `LIBRARY_BOT_ADMIN_USERNAME=` или `LIBRARY_BOT_ADMIN_TELEGRAM_USER_ID=<числовой id>`; первым в бота должен написать именно этот Telegram-аккаунт, после чего его `telegram_user_id` фиксируется в `users.db` как админский. @@ -43,6 +49,15 @@ - Для тестов SQLite путь к БД подменяется через `monkeypatch` в `tests/conftest.py`. - Для новых фич придерживаться test-first подхода: сначала тесты, потом реализация. +## Git Workflow Notes + +- Большие наборы изменений сначала раскладывать по отдельным тематическим веткам, а не копить в одной длинной feature-ветке. +- По возможности держать одну пользовательскую или техническую задачу в одной ветке и в одном PR. +- Коммиты делать атомарными: один логический шаг, в идеале одно изменение в одном файле или в тесно связанном наборе файлов. +- Формат commit message: `feature(): <короткое описание>`, `fix(): <короткое описание>`, `refactor(): <короткое описание>`. +- В body коммита и описании PR писать понятное человеку объяснение: что изменилось, какую проблему это решает и зачем выбран именно такой вариант. +- Если новая ветка зависит от другой тематической ветки, явно сохранять эту зависимость в базе ветки, а не прятать ее в конфликтах при переносе. + ## Pending Architectural Work - Хэндлеры разбиты на `commands.py`, `callbacks.py`, `access.py`, `_helpers.py` и `main.py`. diff --git a/README.md b/README.md index e8480e0..2fffe2e 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,12 @@ Home Library - Telegram-бот для учета домашней библиот Обычный сценарий работы с ботом выглядит так: - Вы фотографируете штрихкод на задней стороне книги. -- Бот распознает ISBN по фотографии. -- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки. -- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию. -- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books и DuckDuckGo. +- Бот распознает ISBN по фотографии и сначала показывает черновик распознанных полей: `ISBN`, `Автор`, `Название`. +- Пользователь может исправить любое поле вручную перед поиском. +- Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год. +- Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки. +- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: РГБ (search.rsl.ru — национальный депозитарий, основной для префикса `978-5-…`), Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. +- Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`. - Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки. - После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше). @@ -21,6 +23,7 @@ Home Library - Telegram-бот для учета домашней библиот - отправьте `/start`, чтобы увидеть краткую справку - отправьте `/search <запрос>` или просто текстовое сообщение, чтобы найти книгу в каталоге - отправьте фото штрихкода, если хотите найти и добавить книгу по ISBN +- при необходимости пришлите фото оборота титульного листа, чтобы бот добрал автора, название и ISBN по выходным сведениям - используйте кнопки в боте, чтобы менять статус, оценку, комментарий, темы и местоположение - если вы админ, используйте `/add_access_to_library` для выдачи Telegram-доступа членам семьи - если вы админ, используйте `/export ` для выгрузки каталога @@ -42,6 +45,16 @@ python -m pip install --upgrade pip python -m pip install -r requirements.txt ``` +Для OCR оборота титульного листа дополнительно нужен системный `tesseract`. + +macOS: + +```bash +brew install tesseract tesseract-lang +``` + +Проверьте, что доступны языки `rus` и `eng`. Без `tesseract` бот всё равно будет работать, но OCR для оборота титульного листа будет недоступен. + Создайте файл `.env` в корне проекта и запишите в него следующее: ```dotenv @@ -59,6 +72,69 @@ python -m home_library Если `library.db` еще нет, приложение само создаст ее из `example_library.xlsx`. +## Опционально: Яндекс через Playwright + +Ряд ISBN (например, `9785961449136`) не находится ни в одном книжном API/HTML — +только в поиске Яндекса. Обычный HTTP к `ya.ru` ловит SmartCaptcha, поэтому +опциональный провайдер ходит через headless Chromium с `playwright-stealth`. + +1. Установите dev-зависимости: + ```bash + python -m pip install -r requirements-dev.txt + ``` +2. Скачайте Chromium (≈170MB): + ```bash + playwright install chromium + ``` +3. Включите провайдер флагом: + ```bash + export YANDEX_ENABLED=1 + ``` + +Без флага провайдер тихо возвращает `None` и бот работает как раньше. Первый +запрос в сессии ≈3–5 с (старт Chromium). Если Яндекс всё же показал капчу — +сработает интерактивная подсказка, бот попросит название/автора и перезапустит +поиск через DuckDuckGo. + +### Каталог РГБ (search.rsl.ru) + +Российская государственная библиотека (включая бывшую Российскую книжную +палату) — национальный депозитарий и хранит данные по большинству книг, +вышедших с префиксом `978-5-…`. Этот провайдер опрашивается **первым** в +основной гонке и обеспечивает наиболее полное покрытие ру-сегмента. + +Включён по умолчанию, не требует отдельных зависимостей. Чтобы выключить — +например, на нестабильной сети: + +```bash +export RSL_RKP_ENABLED=0 +``` + +С `RSL_RKP_ENABLED=0` провайдер мгновенно возвращает `None` и не делает +ни одного сетевого запроса. + +## Фото Оборота Титульного Листа + +Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`. + +Подходящее фото обычно содержит выходные сведения, например: + +```text +Сью Алекс +System Design. Подготовка к сложному интервью. +СПб.: Питер, 2022 +ISBN 978-5-4461-1816-8 +ББК 32.973.2-02 +УДК 004.41 +``` + +Советы для OCR: + +- фотографируйте страницу целиком +- держите камеру ровно над страницей +- избегайте сильных теней и бликов +- лучше присылать именно оборот титульного листа, а не случайную первую страницу текста + ## Первый Запуск - `LIBRARY_BOT_TOKEN` обязателен всегда. Получить токен можно у [@BotFather](https://t.me/BotFather). @@ -166,7 +242,7 @@ python -m home_library ## Важно - `/search` и обычное текстовое сообщение ищут только по уже сохраненным книгам в каталоге домашней библиотеки -- внешний поиск используется только в сценарии добавления книги по фото штрихкода +- внешний поиск используется только в сценарии добавления книги по фото штрихкода или после подтверждения OCR-черновика - экспорт `/export db` отдает только `library.db`; `users.db` в экспорт не входит ## Файлы Данных diff --git a/home_library/config.py b/home_library/config.py index 75997d1..d871214 100644 --- a/home_library/config.py +++ b/home_library/config.py @@ -22,6 +22,11 @@ GOOGLE_BOOKS_URL = "https://www.googleapis.com/books/v1/volumes" +RSL_RKP_BASE_URL = "https://search.rsl.ru" +RSL_RKP_BOOTSTRAP_PATH = "/" +RSL_RKP_AJAX_SEARCH_PATH = "/site/ajax-search?language=ru" +RSL_RKP_RECORD_URL_TEMPLATE = "https://search.rsl.ru/ru/record/{record_id}" +RSL_RKP_ENABLED_ENV = "RSL_RKP_ENABLED" LABIRINT_SEARCH_URL = "https://www.labirint.ru/search/{isbn}/" LABIRINT_HEADERS = {"User-Agent": "Mozilla/5.0"} PITER_SEARCH_URL = "https://www.piter.com/search.json" diff --git a/home_library/interfaces/telegram/formatters.py b/home_library/interfaces/telegram/formatters.py index 7baf745..8c321eb 100644 --- a/home_library/interfaces/telegram/formatters.py +++ b/home_library/interfaces/telegram/formatters.py @@ -4,6 +4,7 @@ from home_library.config import BookEditableField, UserEditableField from home_library.domain.models import BookRecord, UserBookDataRecord +from home_library.interfaces.telegram.handlers._helpers import PendingScanDraft from home_library.storage.sqlite import get_user_by_id @@ -138,3 +139,20 @@ def get_field_label(field: str, user_id: int | None = None) -> str: return user_field.label return field + + +def format_scan_draft(draft: PendingScanDraft, *, from_verso: bool = False) -> str: + """Форматирует черновик распознанных полей для проверки пользователем.""" + title = "Распознал данные с оборота титульного листа:" if from_verso else "Что удалось распознать:" + isbn = escape(draft.isbn) if draft.isbn else "не найден" + author = escape(draft.author) if draft.author else "не найден" + book_title = escape(draft.title) if draft.title else "не найден" + tail = ( + "Всё верно?" + if from_verso + else ( + "Проверь данные перед поиском.\n" + "Если они неточные, можно исправить их вручную или прислать фото оборота титульного листа." + ) + ) + return f"{title}\n\nISBN: {isbn}\nАвтор: {author}\nНазвание: {book_title}\n\n{tail}" diff --git a/home_library/interfaces/telegram/handlers/__init__.py b/home_library/interfaces/telegram/handlers/__init__.py index eb36f24..a06ae19 100644 --- a/home_library/interfaces/telegram/handlers/__init__.py +++ b/home_library/interfaces/telegram/handlers/__init__.py @@ -30,6 +30,7 @@ handle_export_callback, handle_photo, handle_remove_access_callback, + handle_scan_draft_callback, handle_set_callback, ) @@ -86,6 +87,7 @@ "handle_export_callback", "handle_photo", "handle_remove_access_callback", + "handle_scan_draft_callback", "handle_set_callback", "log_unhandled_error", "main", diff --git a/home_library/interfaces/telegram/handlers/_helpers.py b/home_library/interfaces/telegram/handlers/_helpers.py index 289fc6b..14c2cff 100644 --- a/home_library/interfaces/telegram/handlers/_helpers.py +++ b/home_library/interfaces/telegram/handlers/_helpers.py @@ -1,6 +1,8 @@ """Общие константы, протоколы и вспомогательные функции Telegram-хендлеров.""" import logging +from dataclasses import dataclass, field +from datetime import UTC, datetime from io import BytesIO from typing import Any, Protocol, cast @@ -19,6 +21,41 @@ logger = logging.getLogger(__name__) +PENDING_ISBN_HINT_KEY = "pending_isbn_hint" +PENDING_SCAN_DRAFT_KEY = "pending_scan_draft" +SCAN_DRAFT_EDITING_KEY = "scan_draft_editing" +PENDING_BOOK_EDITING_KEY = "pending_book_editing" + + +@dataclass(frozen=True) +class PendingIsbnHint: + """Состояние ожидания подсказки от пользователя по не найденному ISBN.""" + + isbn: str + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass(slots=True) +class PendingScanDraft: + """Черновик данных книги, распознанных с фото до запуска поиска.""" + + token: str + isbn: str = "" + author: str = "" + title: str = "" + awaiting_verso_photo: bool = False + recognized_from_verso: bool = False + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass(slots=True) +class PendingBookEdit: + """Состояние ручной правки книги перед добавлением в каталог.""" + + field: str + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + EDIT_CALLBACK_MIN_PARTS = 3 OPTIONAL_USER_ID_INDEX = 3 SET_CALLBACK_MIN_PARTS = 5 diff --git a/home_library/interfaces/telegram/handlers/callbacks.py b/home_library/interfaces/telegram/handlers/callbacks.py index 2ece5a5..955964b 100644 --- a/home_library/interfaces/telegram/handlers/callbacks.py +++ b/home_library/interfaces/telegram/handlers/callbacks.py @@ -2,20 +2,33 @@ import hashlib import logging +import re +from typing import cast +import httpx from telegram import ( + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, + Message, Update, ) from telegram.ext import ContextTypes +from home_library import config from home_library.domain.models import BookRecord, EditState -from home_library.interfaces.telegram.formatters import escape, format_book, get_field_label +from home_library.interfaces.telegram.formatters import escape, format_book, format_scan_draft, get_field_label from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers._helpers import ( EXPORT_CALLBACK_MIN_PARTS, + PENDING_BOOK_EDITING_KEY, + PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, SET_CALLBACK_MIN_PARTS, + PendingBookEdit, + PendingIsbnHint, + PendingScanDraft, _parse_edit_callback_data, _require_callback_query, _require_message, @@ -23,18 +36,24 @@ _send_export_document, _update_context_repr, ) -from home_library.interfaces.telegram.keyboards import edit_keyboard +from home_library.interfaces.telegram.keyboards import edit_keyboard, pending_book_keyboard, scan_draft_keyboard from home_library.providers.barcode import decode_barcode +from home_library.providers.ddg import fetch_from_ddg_with_context from home_library.providers.lookup import fetch_book_by_isbn +from home_library.providers.title_page import parse_title_page_image from home_library.storage.sqlite import ( add_book, find_existing_book, get_book_by_id, get_users, + search_books, update_book_field, ) logger = logging.getLogger(__name__) +SCAN_CALLBACK_MIN_PARTS = 3 +SCAN_CALLBACK_WITH_FIELD_PARTS = 4 +ADD_BOOK_CALLBACK_WITH_FIELD_PARTS = 3 def _make_pending_book_token(book: BookRecord) -> str: @@ -43,6 +62,367 @@ def _make_pending_book_token(book: BookRecord) -> str: return hashlib.blake2s(token_source.encode("utf-8"), digest_size=8).hexdigest()[:12] +def _make_scan_draft_token(*, isbn: str, author: str, title: str) -> str: + """Строит токен для привязки callback'ов к текущему черновику скана.""" + token_source = f"{isbn}|{author}|{title}" + return hashlib.blake2s(token_source.encode("utf-8"), digest_size=8).hexdigest()[:12] + + +def _clear_scan_draft_state(user_data: dict[str, object]) -> None: + """Очищает временное состояние проверки распознанных полей.""" + user_data.pop(PENDING_SCAN_DRAFT_KEY, None) + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + + +def _parse_scan_callback_data(callback_data: str) -> tuple[str, str, str] | None: + """Парсит callback вида ``scan:action:field?:token``.""" + parts = callback_data.split(":") + if len(parts) == SCAN_CALLBACK_MIN_PARTS: + return parts[1], "", parts[2] + if len(parts) == SCAN_CALLBACK_WITH_FIELD_PARTS: + return parts[1], parts[2], parts[3] + return None + + +def _get_pending_scan_draft(user_data: dict[str, object], token: str) -> PendingScanDraft | None: + """Возвращает черновик скана, если callback относится к актуальному состоянию.""" + draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(draft, PendingScanDraft): + return None + if draft.token != token: + return None + return draft + + +async def _reply_with_scan_draft( + message: Message, + draft: PendingScanDraft, +) -> None: + """Показывает пользователю текущий черновик распознанных полей.""" + await message.reply_text( + format_scan_draft(draft, from_verso=draft.recognized_from_verso), + parse_mode="HTML", + reply_markup=scan_draft_keyboard(draft.token, from_verso=draft.recognized_from_verso), + ) + + +def _merge_title_page_into_draft(draft: PendingScanDraft, *, isbn: str, author: str, title: str) -> PendingScanDraft: + """Объединяет распознанные с оборота титула данные с текущим черновиком.""" + merged_isbn = isbn or draft.isbn + merged_author = author or draft.author + merged_title = title or draft.title + return PendingScanDraft( + token=_make_scan_draft_token(isbn=merged_isbn, author=merged_author, title=merged_title), + isbn=merged_isbn, + author=merged_author, + title=merged_title, + recognized_from_verso=True, + ) + + +def _get_pending_book(user_data: dict[str, object], token: str) -> BookRecord | None: + """Возвращает preview книги, если callback относится к актуальному состоянию.""" + pending_book = user_data.get("pending_book") + pending_book_token = user_data.get("pending_book_token") + if not isinstance(pending_book, BookRecord): + return None + if pending_book_token != token: + return None + return pending_book + + +def _build_pending_book(book: BookRecord, *, field: str, value: str) -> BookRecord: + """Возвращает копию preview книги с обновлённым полем.""" + return book.model_copy(update={field: value}) + + +def _build_pending_book_from_scan_draft(draft: PendingScanDraft) -> BookRecord: + """Собирает preview книги напрямую из подтверждённого scan draft.""" + return BookRecord( + title=draft.title, + author=draft.author, + isbn=re.sub(r"[^0-9Xx]", "", draft.isbn), + ) + + +def _merge_lookup_result_with_manual_fields(book_data: BookRecord, manual_book: BookRecord) -> BookRecord: + """Сохраняет вручную подтверждённые поля поверх данных провайдера.""" + return book_data.model_copy( + update={ + "isbn": manual_book.isbn or book_data.isbn, + "author": manual_book.author or book_data.author, + "title": manual_book.title or book_data.title, + }, + ) + + +async def _reply_with_pending_book_preview( + message: Message, + book: BookRecord, + user_data: dict[str, object], +) -> None: + """Показывает preview книги перед добавлением в каталог.""" + pending_book_token = _make_pending_book_token(book) + user_data["pending_book"] = book + user_data["pending_book_token"] = pending_book_token + await message.reply_text( + f"Найдена книга:\n\n{format_book(book)}\n\nДобавить в каталог?", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=pending_book_keyboard(pending_book_token), + ) + + +async def _show_added_book_result(query: CallbackQuery, book_id: int) -> None: + """Показывает финальное сообщение после успешного добавления книги.""" + book = get_book_by_id(book_id) + if book is None: + msg = ( + f"Книга только что добавлена (id={book_id}), " + f"но не может быть прочитана обратно из БД. " + f"Возможно, проблема с файлом базы данных." + ) + raise RuntimeError(msg) + + keyboard = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "✏️ Заполнить данные", + callback_data=f"edit:{book_id}:menu", + ), + ], + ], + ) + await query.edit_message_text( + f"✅ Книга добавлена в каталог (#{book_id}):\n\n{format_book(book)}", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=keyboard, + ) + + +async def _add_book_and_show_result(query: CallbackQuery, book_data: BookRecord) -> None: + """Добавляет книгу в БД и показывает финальную карточку.""" + try: + book_id = add_book(book_data) + except Exception: + logger.exception( + "Ошибка при добавлении книги title=%r в БД", + book_data.title, + ) + await query.edit_message_text( + "Не удалось добавить книгу в базу данных. Попробуйте позже.", + ) + return + + await _show_added_book_result(query, book_id) + + +async def _prompt_pending_book_field_edit( + query: CallbackQuery, + user_data: dict[str, object], + field: str, +) -> None: + """Переводит пользователя в режим ручной правки preview книги.""" + pending_book = user_data.get("pending_book") + if not isinstance(pending_book, BookRecord): + await query.edit_message_text("Данные книги устарели. Попробуй отправить фото заново.") + return + + current_value = { + "isbn": _build_field_edit_value(pending_book.isbn), + "author": _build_field_edit_value(pending_book.author), + "title": _build_field_edit_value(pending_book.title), + }[field] + user_data[PENDING_BOOK_EDITING_KEY] = PendingBookEdit(field=field) + await query.edit_message_text(current_value) + message = _require_callback_message(query) + await message.reply_text(_build_field_edit_instruction()) + + +async def _search_books_from_scan_draft(message: Message, query_text: str) -> None: + """Выполняет текстовый поиск, запущенный из черновика распознавания.""" + try: + results = search_books(query_text) + except Exception: + logger.exception("Ошибка при поиске книг из scan draft query=%r", query_text) + await message.reply_text("Произошла ошибка при поиске. Попробуйте позже.") + return + + if not results: + await message.reply_text(f"По запросу «{query_text}» ничего не найдено.") + return + + header = f"Найдено: {len(results)}" + if len(results) == config.SEARCH_LIMIT: + header += f" (показаны первые {config.SEARCH_LIMIT}, уточни запрос)" + + for index, book in enumerate(results): + if book.id is None: + msg = ( + f"Книга из результатов поиска не имеет id " + f"(title={book.title!r}, query={query_text!r}). " + f"Возможно, нарушена целостность БД." + ) + raise RuntimeError(msg) + + text = format_book(book) + if index == 0: + text = f"{header}\n\n{text}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("✏️ Редактировать", callback_data=f"edit:{book.id}:menu")]], + ) + await message.reply_text( + text, + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=keyboard, + ) + + +def _build_title_page_verso_prompt() -> str: + """Возвращает подсказку для фото оборота титульного листа.""" + return ( + "Пришли фото оборота титульного листа.\n\n" + "На этой странице обычно есть выходные сведения:\n" + "Сью Алекс\n" + "System Design. Подготовка к сложному интервью.\n" + "СПб.: Питер, 2022\n" + "ISBN 978-5-4461-1816-8\n" + "ББК 32.973.2-02\n" + "УДК 004.41\n\n" + "Постарайся сфотографировать страницу целиком, ровно и без сильных теней." + ) + + +def _build_field_edit_value(value: str) -> str: + """Возвращает текущее значение поля для удобного копирования.""" + return value.strip() or "не найден" + + +def _build_field_edit_instruction() -> str: + """Возвращает инструкцию для ручной правки поля.""" + return "Введи новое значение:\n(или /cancel для отмены)" + + +async def _answer_edit_in_progress(query: CallbackQuery) -> None: + """Сообщает, что нужно завершить активное редактирование поля.""" + await query.answer("Сначала заверши редактирование поля.", show_alert=True) + + +async def _prompt_scan_draft_field_edit( + query: CallbackQuery, + user_data: dict[str, object], + field: str, +) -> None: + """Переводит пользователя в режим ручного редактирования одного поля.""" + pending_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(pending_draft, PendingScanDraft): + await query.edit_message_text("Данные распознавания устарели. Отправь фото заново.") + return + + current_value = { + "isbn": _build_field_edit_value(pending_draft.isbn), + "author": _build_field_edit_value(pending_draft.author), + "title": _build_field_edit_value(pending_draft.title), + }[field] + user_data[SCAN_DRAFT_EDITING_KEY] = field + await query.edit_message_text(current_value) + message = _require_callback_message(query) + await message.reply_text(_build_field_edit_instruction()) + + +def _require_callback_message(query: CallbackQuery) -> Message: + """Возвращает callback message, если она доступна для ответов.""" + message = cast("Message | None", query.message) + if message is None: + raise RuntimeError("Callback scan draft не содержит message для продолжения поиска.") + return message + + +async def _continue_scan_draft( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_data: dict[str, object], + query: CallbackQuery, + draft: PendingScanDraft, +) -> None: + """Ищет данные книги по текущим полям scan draft.""" + manual_book = _build_pending_book_from_scan_draft(draft) + await _lookup_book_by_fields( + update, + context, + user_data, + query, + message=_require_callback_message(query), + manual_book=manual_book, + clear_scan_draft=True, + ) + + +async def _lookup_book_by_fields( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_data: dict[str, object], + query: CallbackQuery, + *, + message: Message, + manual_book: BookRecord, + clear_scan_draft: bool = False, +) -> None: + """Ищет информацию о книге по текущим вручную подтверждённым полям.""" + if not any((manual_book.isbn, manual_book.author, manual_book.title)): + await query.answer( + "Заполни хотя бы ISBN, автора или название перед поиском.", + show_alert=True, + ) + return + + if clear_scan_draft: + _clear_scan_draft_state(user_data) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + await query.edit_message_text("Ищу информацию о книге...") + + normalized_isbn = re.sub(r"[^0-9Xx]", "", manual_book.isbn) + if normalized_isbn: + await message.reply_text( + f"Ищу книгу по ISBN {escape(normalized_isbn)}...", + parse_mode="HTML", + ) + try: + book_data = await fetch_book_by_isbn(normalized_isbn) + except Exception: + logger.exception("Ошибка при поиске книги по ISBN %s из scan draft", normalized_isbn) + await message.reply_text( + f"Произошла ошибка при поиске книги по ISBN {normalized_isbn}.\nПопробуйте позже.", + ) + return + + if book_data: + merged_book = _merge_lookup_result_with_manual_fields(book_data, manual_book) + await _present_book_for_isbn(update, context, message, merged_book, merged_book.isbn or normalized_isbn) + return + + if manual_book.author or manual_book.title: + query_text = " ".join(part for part in [manual_book.author, manual_book.title] if part).strip() + await message.reply_text("По ISBN книга не нашлась, пробую поиск по автору и названию...") + await _search_books_from_scan_draft(message, query_text) + return + + user_data[PENDING_ISBN_HINT_KEY] = PendingIsbnHint(isbn=normalized_isbn) + await message.reply_text( + f"Книга с ISBN {normalized_isbn} не найдена.\n" + "Напиши название, автора или часть — попробую с подсказкой (или /cancel).", + ) + return + + query_text = " ".join(part for part in [manual_book.author, manual_book.title] if part).strip() + await message.reply_text("Ищу книгу по названию и автору...") + await _search_books_from_scan_draft(message, query_text) + + async def handle_export_callback( update: Update, _context: ContextTypes.DEFAULT_TYPE, @@ -126,10 +506,10 @@ async def handle_remove_access_callback( async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает фото со штрихкодом: распознаёт ISBN и ищет книгу. + """Обрабатывает фото: собирает черновик ISBN/автора/названия и запускает поиск позже. - Полный flow: фото → decode_barcode → fetch_book_by_isbn → - проверка дубликата → подтверждение добавления. + Полный flow: фото штрихкода/оборота титула → черновик распознавания → + подтверждение пользователя → поиск книги. Args: update: Telegram update с фото. @@ -150,34 +530,228 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No file = await photo.get_file() image_bytes = bytes(await file.download_as_bytearray()) - isbn = decode_barcode(image_bytes) - if not isbn: - await message.reply_text( - "Не удалось распознать штрихкод на фото.\nПопробуй сфотографировать его крупнее и ровнее.", + user_data = _require_user_data(context) + pending_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_draft, PendingScanDraft) and pending_draft.awaiting_verso_photo: + parsed_page = parse_title_page_image(image_bytes) + if parsed_page is None or not any((parsed_page.isbn, parsed_page.author, parsed_page.title)): + await message.reply_text( + "Не удалось распознать данные с оборота титульного листа.\n" + "Попробуй сфотографировать страницу целиком, ровно и без сильных теней.", + ) + return + + merged_draft = _merge_title_page_into_draft( + pending_draft, + isbn=parsed_page.isbn, + author=parsed_page.author, + title=parsed_page.title, ) + user_data[PENDING_SCAN_DRAFT_KEY] = merged_draft + await _reply_with_scan_draft(message, merged_draft) return - await message.reply_text( - f"📦 Найден ISBN: {escape(isbn)}\nИщу книгу...", - parse_mode="HTML", + isbn = decode_barcode(image_bytes) or "" + draft = PendingScanDraft( + token=_make_scan_draft_token(isbn=isbn, author="", title=""), + isbn=isbn, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = draft + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await _reply_with_scan_draft(message, draft) + + +async def handle_isbn_hint( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + hint: str, + pending: PendingIsbnHint, +) -> None: + """Дообходит подсказку от пользователя: ищет книгу по ISBN + hint. + + Используется как safety-net когда первичный поиск по ISBN не нашёл книгу. + """ + message = _require_message(update) + logger.info( + "Handling ISBN hint isbn=%s hint=%r for %s", + pending.isbn, + hint, + _update_context_repr(update), ) try: - book_data = await fetch_book_by_isbn(isbn) + async with httpx.AsyncClient(timeout=10) as client: + book_data = await fetch_from_ddg_with_context(pending.isbn, hint, client) except Exception: - logger.exception("Ошибка при поиске книги по ISBN %s", isbn) + logger.exception("Ошибка при поиске по подсказке для ISBN %s", pending.isbn) await message.reply_text( - f"Произошла ошибка при поиске книги по ISBN {isbn}.\nПопробуйте позже.", + "Произошла ошибка при поиске по подсказке. Попробуй ещё раз.", ) return if not book_data: - logger.info("ISBN %s: ни один провайдер не вернул данных", isbn) await message.reply_text( - f"Книга с ISBN {isbn} не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск.", + "По подсказке тоже ничего не нашлось. Попробуй другой ISBN или добавь книгу вручную.", ) return + await _present_book_for_isbn(update, context, message, book_data, pending.isbn) + + +async def handle_scan_draft_callback( # noqa: PLR0911 + update: Update, + context: ContextTypes.DEFAULT_TYPE, +) -> None: + """Обрабатывает inline-действия по проверке распознанных полей книги.""" + if not await _access._ensure_access(update): + return + query = _require_callback_query(update) + await query.answer() + user_data = _require_user_data(context) + parsed = _parse_scan_callback_data(query.data or "") + if parsed is None: + await query.edit_message_text("Ошибка: некорректные данные кнопки.") + return + + action, field, token = parsed + draft = _get_pending_scan_draft(user_data, token) + if draft is None: + await query.edit_message_text("Данные распознавания устарели. Отправь фото заново.") + return + + if action == "cancel": + _clear_scan_draft_state(user_data) + await query.edit_message_text("Отменено.") + return + + if action == "edit" and field in {"isbn", "author", "title"}: + await _prompt_scan_draft_field_edit(query, user_data, field) + return + + if action == "verso": + draft.awaiting_verso_photo = True + user_data[PENDING_SCAN_DRAFT_KEY] = draft + await query.edit_message_text(_build_title_page_verso_prompt()) + return + + if isinstance(user_data.get(SCAN_DRAFT_EDITING_KEY), str): + await _answer_edit_in_progress(query) + return + + if action == "confirm": + _clear_scan_draft_state(user_data) + user_data.pop("pending_book", None) + user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + book_data = _build_pending_book_from_scan_draft(draft) + existing = find_existing_book(book_data.title, book_data.author, isbn=book_data.isbn) + if existing is not None: + if existing.id is None: + msg = f"Существующая книга (title={existing.title!r}) не имеет id. Нарушена целостность данных в БД." + raise RuntimeError(msg) + + await query.edit_message_text( + f"Книга уже есть в каталоге:\n\n{format_book(existing)}", + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "✏️ Редактировать", + callback_data=f"edit:{existing.id}:menu", + ), + ], + ], + ), + ) + return + + await _add_book_and_show_result(query, book_data) + return + + if action == "lookup": + await _continue_scan_draft(update, context, user_data, query, draft) + return + + await query.edit_message_text("Ошибка: неизвестное действие проверки данных.") + + +async def handle_scan_draft_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку поля в scan draft и снова показывает черновик.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(draft, PendingScanDraft): + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_isbn = cleaned_value if field == "isbn" else draft.isbn + updated_author = cleaned_value if field == "author" else draft.author + updated_title = cleaned_value if field == "title" else draft.title + updated = PendingScanDraft( + token=_make_scan_draft_token(isbn=updated_isbn, author=updated_author, title=updated_title), + isbn=updated_isbn, + author=updated_author, + title=updated_title, + recognized_from_verso=draft.recognized_from_verso, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = updated + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_scan_draft(message, updated) + return True + + +async def handle_pending_book_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку preview книги и снова показывает её перед добавлением.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + pending_book = user_data.get("pending_book") + if not isinstance(pending_book, BookRecord): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_book = _build_pending_book(pending_book, field=field, value=cleaned_value) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_pending_book_preview(message, updated_book, user_data) + return True + + +async def _present_book_for_isbn( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + message: Message, + book_data: BookRecord, + isbn: str, +) -> None: + """Показывает найденную книгу: либо карточку существующей, либо preview для добавления.""" existing = find_existing_book(book_data.title, book_data.author, isbn=isbn) if existing: if existing.id is None: @@ -208,40 +782,17 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No return user_data = _require_user_data(context) - pending_book_token = _make_pending_book_token(book_data) - user_data["pending_book"] = book_data - user_data["pending_book_token"] = pending_book_token + user_data.pop(PENDING_BOOK_EDITING_KEY, None) logger.info( "Подготовлена книга к добавлению: title=%r, isbn=%r, for %s", book_data.title, isbn, _update_context_repr(update), ) + await _reply_with_pending_book_preview(message, book_data.model_copy(), user_data) - keyboard = InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "✅ Добавить", - callback_data=f"add_book_yes:{pending_book_token}", - ), - InlineKeyboardButton( - "❌ Отмена", - callback_data=f"add_book_no:{pending_book_token}", - ), - ], - ], - ) - preview_book = book_data.model_copy() - await message.reply_text( - f"Найдена книга:\n\n{format_book(preview_book)}\n\nДобавить в каталог?", - parse_mode="HTML", - disable_web_page_preview=True, - reply_markup=keyboard, - ) - -async def handle_add_book_callback( +async def handle_add_book_callback( # noqa: PLR0911 update: Update, context: ContextTypes.DEFAULT_TYPE, ) -> None: @@ -258,15 +809,45 @@ async def handle_add_book_callback( ) callback_data = query.data or "" - action, _, callback_token = callback_data.partition(":") - pending_book_token = user_data.get("pending_book_token") + parts = callback_data.split(":") + action = parts[0] if parts else "" + field = parts[1] if len(parts) == ADD_BOOK_CALLBACK_WITH_FIELD_PARTS else "" + callback_token = ( + parts[2] if len(parts) == ADD_BOOK_CALLBACK_WITH_FIELD_PARTS else (parts[1] if len(parts) > 1 else "") + ) - if not callback_token or callback_token != pending_book_token: + if not callback_token or _get_pending_book(user_data, callback_token) is None: await query.edit_message_text( "Данные книги устарели. Попробуй отправить фото заново.", ) return + if action == "add_book_edit" and field in {"isbn", "author", "title"}: + await _prompt_pending_book_field_edit(query, user_data, field) + return + + if action in {"add_book_yes", "add_book_lookup"} and isinstance( + user_data.get(PENDING_BOOK_EDITING_KEY), + PendingBookEdit, + ): + await _answer_edit_in_progress(query) + return + + if action == "add_book_lookup": + pending_book = _get_pending_book(user_data, callback_token) + if pending_book is None: + await query.edit_message_text("Данные книги устарели. Попробуй отправить фото заново.") + return + await _lookup_book_by_fields( + update, + context, + user_data, + query, + message=_require_callback_message(query), + manual_book=pending_book, + ) + return + if action not in {"add_book_yes", "add_book_no"}: await query.edit_message_text("Ошибка: некорректные данные кнопки.") return @@ -275,53 +856,19 @@ async def handle_add_book_callback( await query.edit_message_text("Отменено.") user_data.pop("pending_book", None) user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) return book_data_value = user_data.pop("pending_book", None) user_data.pop("pending_book_token", None) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) if not isinstance(book_data_value, BookRecord): await query.edit_message_text( "Данные книги не найдены. Попробуй отправить фото заново.", ) return - try: - book_id = add_book(book_data_value) - except Exception: - logger.exception( - "Ошибка при добавлении книги title=%r в БД", - book_data_value.title, - ) - await query.edit_message_text( - "Не удалось добавить книгу в базу данных. Попробуйте позже.", - ) - return - - book = get_book_by_id(book_id) - if book is None: - msg = ( - f"Книга только что добавлена (id={book_id}), " - f"но не может быть прочитана обратно из БД. " - f"Возможно, проблема с файлом базы данных." - ) - raise RuntimeError(msg) - - keyboard = InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "✏️ Заполнить данные", - callback_data=f"edit:{book_id}:menu", - ), - ], - ], - ) - await query.edit_message_text( - f"✅ Книга добавлена в каталог (#{book_id}):\n\n{format_book(book)}", - parse_mode="HTML", - disable_web_page_preview=True, - reply_markup=keyboard, - ) + await _add_book_and_show_result(query, book_data_value) async def handle_edit_callback( diff --git a/home_library/interfaces/telegram/handlers/commands.py b/home_library/interfaces/telegram/handlers/commands.py index 52c8ff9..c6bcf82 100644 --- a/home_library/interfaces/telegram/handlers/commands.py +++ b/home_library/interfaces/telegram/handlers/commands.py @@ -13,7 +13,15 @@ from home_library.domain.models import EditState from home_library.interfaces.telegram.formatters import format_book, get_field_label from home_library.interfaces.telegram.handlers import access as _access +from home_library.interfaces.telegram.handlers import callbacks as _callbacks from home_library.interfaces.telegram.handlers._helpers import ( + PENDING_BOOK_EDITING_KEY, + PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, + PendingBookEdit, + PendingIsbnHint, + PendingScanDraft, _build_export_keyboard, _format_telegram_username, _require_message, @@ -183,12 +191,26 @@ async def remove_access_to_library_command(update: Update, _context: ContextType await _access._show_remove_access_command(message) -async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # noqa: PLR0911 """Отменяет активный режим редактирования.""" if not await _access._ensure_access(update): return message = _require_message(update) user_data = _require_user_data(context) + if user_data.pop(PENDING_ISBN_HINT_KEY, None) is not None: + await message.reply_text("Ожидание подсказки по ISBN отменено.") + return + pending_scan_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_scan_draft, PendingScanDraft): + user_data.pop(PENDING_SCAN_DRAFT_KEY, None) + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await message.reply_text("Проверка распознанных данных отменена.") + return + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + await message.reply_text("Редактирование книги перед добавлением отменено.") + return if user_data.pop("awaiting_access_username", None) is not None: await message.reply_text("Привязка Telegram username отменена.") return @@ -290,19 +312,72 @@ async def text_search(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> No await do_search(update, query) -async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает текстовый ввод при редактировании поля. - - Если пользователь не в режиме редактирования — обрабатывает - текст как поисковый запрос. - """ - if not await _access._ensure_access(update): +async def _consume_pending_isbn_hint( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + message_text: str, + pending: PendingIsbnHint, +) -> None: + """Обрабатывает подсказку по ISBN: валидирует ввод и делегирует в callbacks.""" + message = _require_message(update) + user_data = _require_user_data(context) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + hint = message_text.strip() + if not hint: + await message.reply_text("Пустая подсказка. Попробуй ещё раз или /cancel.") return + await _callbacks.handle_isbn_hint(update, context, hint, pending) + + +async def _consume_scan_draft_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование полей scan draft.""" + return await _callbacks.handle_scan_draft_field_input(update, context, field, message_text) + + +async def _consume_pending_book_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование preview книги перед добавлением.""" + return await _callbacks.handle_pending_book_field_input(update, context, field, message_text) + + +async def _handle_non_editing_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + message_text: str, +) -> bool: + """Обрабатывает текст вне режима DB-редактирования книги.""" message = _require_message(update) user_data = _require_user_data(context) + + pending_hint = user_data.get(PENDING_ISBN_HINT_KEY) + if isinstance(pending_hint, PendingIsbnHint): + await _consume_pending_isbn_hint(update, context, message_text, pending_hint) + return True + + scan_draft_editing = user_data.get(SCAN_DRAFT_EDITING_KEY) + if isinstance(scan_draft_editing, str): + handled = await _consume_scan_draft_edit(update, context, scan_draft_editing, message_text) + if handled: + return True + + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + handled = await _consume_pending_book_edit(update, context, pending_book_editing.field, message_text) + if handled: + return True + awaiting_access_username = user_data.get("awaiting_access_username") if awaiting_access_username is True: - username = (message.text or "").strip() + username = message_text.strip() admin_user = update.effective_user if admin_user is None: raise RuntimeError("Telegram update не содержит effective_user для настройки доступа.") @@ -313,13 +388,30 @@ async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) except ValueError as exc: await message.reply_text(f"Ошибка: {exc}") - return + return True user_data.pop("awaiting_access_username", None) await message.reply_text( f"Ожидаю первое сообщение от {_format_telegram_username(pending_link.expected_telegram_username)}.", reply_markup=_access._build_access_keyboard(), ) + return True + + return False + + +async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Обрабатывает текстовый ввод при редактировании поля. + + Если пользователь не в режиме редактирования — обрабатывает + текст как поисковый запрос. + """ + if not await _access._ensure_access(update): + return + message = _require_message(update) + user_data = _require_user_data(context) + + if await _handle_non_editing_text(update, context, message.text or ""): return editing_value = user_data.get("editing") diff --git a/home_library/interfaces/telegram/handlers/main.py b/home_library/interfaces/telegram/handlers/main.py index 24c67a9..ea07139 100644 --- a/home_library/interfaces/telegram/handlers/main.py +++ b/home_library/interfaces/telegram/handlers/main.py @@ -108,6 +108,7 @@ def main() -> None: app.add_handler( CallbackQueryHandler(_callbacks.handle_add_book_callback, pattern="^add_book_"), ) + app.add_handler(CallbackQueryHandler(_callbacks.handle_scan_draft_callback, pattern="^scan:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_access_callback, pattern="^access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_remove_access_callback, pattern="^remove_access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_export_callback, pattern="^export:")) diff --git a/home_library/interfaces/telegram/keyboards.py b/home_library/interfaces/telegram/keyboards.py index 45eeb42..c6a53eb 100644 --- a/home_library/interfaces/telegram/keyboards.py +++ b/home_library/interfaces/telegram/keyboards.py @@ -43,3 +43,35 @@ def edit_keyboard(book_id: int) -> InlineKeyboardMarkup: [InlineKeyboardButton("✅ Готово", callback_data=f"edit:{book_id}:done")], ] return InlineKeyboardMarkup(buttons) + + +def scan_draft_keyboard(token: str, *, from_verso: bool = False) -> InlineKeyboardMarkup: + """Строит inline-клавиатуру для проверки распознанных полей книги.""" + verso_label = "📖 Прислать другое фото оборота" if from_verso else "📖 Пришлю оборот титульного листа" + buttons = [ + [ + InlineKeyboardButton("✏️ ISBN", callback_data=f"scan:edit:isbn:{token}"), + InlineKeyboardButton("✏️ Автор", callback_data=f"scan:edit:author:{token}"), + ], + [InlineKeyboardButton("✏️ Название", callback_data=f"scan:edit:title:{token}")], + [InlineKeyboardButton(verso_label, callback_data=f"scan:verso:{token}")], + [InlineKeyboardButton("✅ Всё верно, добавить в библиотеку", callback_data=f"scan:confirm:{token}")], + [InlineKeyboardButton("🔎 Искать информацию", callback_data=f"scan:lookup:{token}")], + [InlineKeyboardButton("❌ Отмена", callback_data=f"scan:cancel:{token}")], + ] + return InlineKeyboardMarkup(buttons) + + +def pending_book_keyboard(token: str) -> InlineKeyboardMarkup: + """Строит inline-клавиатуру для правки preview книги перед добавлением.""" + buttons = [ + [ + InlineKeyboardButton("✏️ ISBN", callback_data=f"add_book_edit:isbn:{token}"), + InlineKeyboardButton("✏️ Автор", callback_data=f"add_book_edit:author:{token}"), + ], + [InlineKeyboardButton("✏️ Название", callback_data=f"add_book_edit:title:{token}")], + [InlineKeyboardButton("🔎 Искать информацию", callback_data=f"add_book_lookup:{token}")], + [InlineKeyboardButton("✅ Добавить", callback_data=f"add_book_yes:{token}")], + [InlineKeyboardButton("❌ Отмена", callback_data=f"add_book_no:{token}")], + ] + return InlineKeyboardMarkup(buttons) diff --git a/home_library/providers/_serp_parser.py b/home_library/providers/_serp_parser.py new file mode 100644 index 0000000..4f09f19 --- /dev/null +++ b/home_library/providers/_serp_parser.py @@ -0,0 +1,274 @@ +"""Парсер поисковой выдачи (SERP). + +Общая логика для извлечения названия и автора книги из заголовков и +сниппетов поисковых движков. Используется провайдерами DuckDuckGo и +Yandex: они отличаются только способом получения ``titles``/``snippets``, +а очистка и выбор лучшего кандидата одинаковые. +""" + +import html +import re + +from home_library import config +from home_library.domain.models import BookRecord + +MIN_MEANINGFUL_TITLE_LENGTH = 3 +AUTHOR_LABEL_WORD_COUNT = 2 +AUTHOR_NAME_PART = r"[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*" + +ALPINA_SERIES_PREFIX_PATTERN = re.compile(r"^АНФ\.", re.IGNORECASE) +MARKETPLACE_BOOK_PREFIX_PATTERN = re.compile( + r"^Нехудожественная книга\s+[А-ЯЁA-Za-z-]+\s+", + re.IGNORECASE, +) +AGE_MARK_PATTERN = re.compile(r"\s*\(\d+\+\)") +ARTICLE_SUFFIX_PATTERN = re.compile(r"\s+арт\.\s*\d{10,13}\s*$", re.IGNORECASE) +EDITION_SUFFIX_PATTERN = re.compile(r"\.\s*\d+-е изд\..*$", re.IGNORECASE) +PUBLISHER_SUFFIX_PATTERN = re.compile( + r"\s+Альпина(?:\s*\.{2,}|\.|$).*", + re.IGNORECASE, +) +AUTHOR_LABEL_PATTERN = re.compile( + rf"\bавтор\s+(?P{AUTHOR_NAME_PART}(?:\s+{AUTHOR_NAME_PART}){{1,2}}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)", + re.IGNORECASE, +) +AMAZON_AUTHOR_PATTERN = re.compile( + r":\s*97[89]\d{10}\s*:\s*(?P[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){1,2})\s*:\s*Books", +) +TITLE_NOISE_PATTERN = re.compile(r"/|\bизд\b", re.IGNORECASE) +AUTHOR_TITLE_PATTERN = re.compile( + rf"^(?P{AUTHOR_NAME_PART}(?:\s+{AUTHOR_NAME_PART}){{1,2}}):\s+(?P.+)$", +) + + +def _clean_title(raw_title: str) -> str: + """Очищает заголовок результата поиска от шумовых суффиксов. + + Убирает названия магазинов (Ozon, Amazon, Wildberries, ...), + доменные префиксы, marketplace-префиксы, возрастные метки, + артикульные и edition-суффиксы, а также завершающие многоточия. + + Args: + raw_title: Сырой заголовок из поисковой выдачи. + + Returns: + Очищенный заголовок. + + Example:: + + >>> _clean_title("Война и мир - Ozon") + 'Война и мир' + >>> _clean_title("ozon.ru: Война и мир...") + 'Война и мир' + """ + cleaned_title = raw_title + + # Отсекаем всё после разделителей магазинов и маркетплейсов + for separator in ( + " | ", + " - Ozon", + " - Amazon", + " - Wildberries", + " - Читай-город", + " - Лабиринт", + " - купить в", + " купить", + " Купить", + ): + cleaned_title = cleaned_title.split(separator)[0] + + cleaned_title = cleaned_title.strip(config.STRIP_CHARS) + cleaned_title = config.TITLE_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = ALPINA_SERIES_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = MARKETPLACE_BOOK_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = AGE_MARK_PATTERN.sub("", cleaned_title) + cleaned_title = ARTICLE_SUFFIX_PATTERN.sub("", cleaned_title) + cleaned_title = EDITION_SUFFIX_PATTERN.sub("", cleaned_title) + cleaned_title = PUBLISHER_SUFFIX_PATTERN.sub("", cleaned_title) + cleaned_title = config.TITLE_SUFFIX_PATTERN.sub("", cleaned_title) + return cleaned_title.strip(config.STRIP_CHARS) + + +def _normalize_author(author: str, *, from_author_label: bool = False) -> str: + """Нормализует автора, извлечённого из поисковой выдачи.""" + normalized_author = html.unescape(author).strip(config.STRIP_CHARS + ":;,") + if not from_author_label: + return normalized_author + + parts = normalized_author.split() + if len(parts) != AUTHOR_LABEL_WORD_COUNT: + return normalized_author + if not all(config.CYRILLIC_PATTERN.search(part) for part in parts): + return normalized_author + if any("." in part for part in parts): + return normalized_author + if "-" in parts[1] or len(parts[1]) > len(parts[0]) + AUTHOR_LABEL_WORD_COUNT: + return normalized_author + + # Сниппеты каталогов часто отдают автора в формате «Фамилия Имя» — переворачиваем. + return f"{parts[1]} {parts[0]}" + + +def _extract_author_from_snippets(snippets: list[str]) -> str: + """Пытается извлечь автора из сниппетов результатов поиска.""" + for raw_snippet in snippets: + snippet = html.unescape(raw_snippet) + author_match = AUTHOR_LABEL_PATTERN.search(snippet) + if author_match: + return _normalize_author( + author_match.group("author"), + from_author_label=True, + ) + + for raw_snippet in snippets: + snippet = html.unescape(raw_snippet) + author_match = AMAZON_AUTHOR_PATTERN.search(snippet) + if author_match: + return _normalize_author(author_match.group("author")) + + return "" + + +def _title_noise_score(title: str) -> int: + """Возвращает грубую оценку шумности заголовка из поисковой выдачи.""" + return len(TITLE_NOISE_PATTERN.findall(title)) + + +def _split_author_and_title(cleaned_title: str) -> tuple[str, str] | None: + """Пытается разобрать заголовок вида ``Автор: Название``.""" + author_match = AUTHOR_TITLE_PATTERN.match(cleaned_title) + if not author_match: + return None + + author = _normalize_author( + author_match.group("author"), + from_author_label=True, + ) + title = author_match.group("title").strip(config.STRIP_CHARS) + if len(title) <= MIN_MEANINGFUL_TITLE_LENGTH: + return None + return author, title + + +def _looks_truncated(raw_title: str) -> bool: + """Определяет, что поисковик обрезал заголовок многоточием.""" + return "..." in raw_title or "…" in raw_title + + +def _merge_truncated_titles(cleaned_titles: list[str], truncated_titles: set[str]) -> list[str]: + """Подменяет усечённые заголовки их более полными вариантами.""" + merged: list[str] = [] + for title in cleaned_titles: + replacement = title + if title in truncated_titles: + for candidate in cleaned_titles: + if candidate == title: + continue + if candidate.lower().startswith(title.lower()) and len(candidate) > len(replacement): + replacement = candidate + merged.append(replacement) + return merged + + +def extract_book_from_serp( + titles: list[str], + isbn: str, + snippets: list[str] | None = None, +) -> BookRecord | None: + """Пытается извлечь книгу из набора заголовков поисковой выдачи. + + Алгоритм: + 1. Отбираем заголовки с кириллицей (русскоязычные книги). + 2. Очищаем каждый от шума через ``_clean_title``. + 3. Разбиваем заголовки с «—» на пару (автор, название). + 4. Выбираем наиболее частый очищенный заголовок как название. + 5. Автора берём из пар «автор — название» или из сниппетов. + + Args: + titles: Список заголовков результатов поиска. + isbn: ISBN для записи в результат. + snippets: Опциональные сниппеты, из которых пробуем достать автора. + + Returns: + ``BookRecord`` с извлечёнными данными или ``None``, + если не удалось найти подходящий заголовок. + + Example:: + + >>> titles = [ + ... "Толстой — Война и мир", + ... "Война и мир | Лабиринт", + ... "Война и мир купить", + ... ] + >>> book = extract_book_from_serp(titles, "9785171234567") + >>> book.title + 'Война и мир' + >>> book.author + 'Толстой' + """ + russian_titles = [title for title in titles if config.CYRILLIC_PATTERN.search(title)] + if not russian_titles: + return None + + cleaned: list[str] = [] + truncated_cleaned: set[str] = set() + em_dash_parts: list[tuple[str, str]] = [] + author_title_parts: list[tuple[str, str]] = [] + for raw_title in russian_titles: + cleaned_title = _clean_title(raw_title) + if len(cleaned_title) <= MIN_MEANINGFUL_TITLE_LENGTH: + continue + if " — " in cleaned_title: + left, right = cleaned_title.split(" — ", 1) + em_dash_parts.append((left.strip(), right.strip())) + cleaned.append(right.strip()) + elif author_title_part := _split_author_and_title(cleaned_title): + author, title = author_title_part + author_title_parts.append((author, title)) + cleaned.append(title) + else: + cleaned.append(cleaned_title) + + if _looks_truncated(raw_title): + truncated_cleaned.add(cleaned[-1]) + + if not cleaned: + return None + + cleaned = _merge_truncated_titles(cleaned, truncated_cleaned) + + lowered = [title.lower() for title in cleaned] + best = max( + cleaned, + key=lambda candidate: ( + lowered.count(candidate.lower()), + -_title_noise_score(candidate), + len(candidate), + ), + ) + + author = "" + for left, right in em_dash_parts: + if right.lower() == best.lower(): + author = left + break + if not author and em_dash_parts: + author = em_dash_parts[0][0] + if not author: + for candidate_author, candidate_title in author_title_parts: + if best.lower() == candidate_title.lower() or best.lower().startswith(candidate_title.lower()): + author = candidate_author + break + if not author and author_title_parts: + author = author_title_parts[0][0] + if not author and snippets: + author = _extract_author_from_snippets(snippets) + + return BookRecord( + title=best, + author=author, + publisher="", + topics="", + link="", + isbn=isbn, + ) diff --git a/home_library/providers/ddg.py b/home_library/providers/ddg.py index c0e3aa0..7417b3a 100644 --- a/home_library/providers/ddg.py +++ b/home_library/providers/ddg.py @@ -1,9 +1,11 @@ """DuckDuckGo provider adapter. Ищет книгу по ISBN через HTML-версию DuckDuckGo, парсит результаты -и пытается извлечь название и автора из заголовков. +и пытается извлечь название и автора из заголовков. Логика разбора +вынесена в ``_serp_parser`` и переиспользуется с Yandex-провайдером. """ +import html import logging import re from http import HTTPStatus @@ -12,141 +14,76 @@ from home_library import config from home_library.domain.models import BookRecord - -MIN_MEANINGFUL_TITLE_LENGTH = 3 +from home_library.providers._serp_parser import extract_book_from_serp logger = logging.getLogger(__name__) +DDG_SEARCH_URL = "https://html.duckduckgo.com/html/" +DDG_USER_AGENT = "Mozilla/5.0" + +RESULT_TITLE_PATTERN = re.compile( + r'class="result__a"[^>]*>(.*?)</a', + re.DOTALL, +) +RESULT_SNIPPET_PATTERN = re.compile( + r'class="result__snippet"[^>]*>(.*?)</(?: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"[^>]*>(.*?)</a', - resp.text, - re.DOTALL, - ) - # Убираем HTML-теги из заголовков и ограничиваем количество - titles = [config.TITLE_HTML_PATTERN.sub("", title).strip() for title in titles_html[: config.SEARCH_LIMIT]] - return _extract_ddg_book(titles, isbn) - except httpx.HTTPError as exc: - logger.warning( - "Сетевая ошибка при поиске через DuckDuckGo (ISBN %s): %s — %s", - isbn, - type(exc).__name__, - exc, - ) - return None - except (KeyError, TypeError, ValueError) as exc: - logger.warning( - "Ошибка парсинга результатов DuckDuckGo (ISBN %s): %s — %s", - isbn, - type(exc).__name__, - exc, - ) - return None +async def fetch_from_ddg_with_context( + isbn: str, + hint: str, + client: httpx.AsyncClient, +) -> BookRecord | None: + """Ищет книгу по ISBN + подсказке пользователя через DuckDuckGo. + + Используется как safety-net, когда поиск по одному ISBN не нашёл книгу, + а пользователь подсказал автора или часть названия. + + Args: + isbn: ISBN-код для поиска. + hint: Подсказка от пользователя (часть названия, автор и т.д.). + client: Async HTTP-клиент (httpx). + + Returns: + ``BookRecord`` или ``None``, если поиск не дал результатов. + """ + normalized_hint = hint.strip() + if not normalized_hint: + return await fetch_from_ddg(isbn, client) + query = f"{isbn} {normalized_hint}" + return await _search_ddg(query, isbn, client) diff --git a/home_library/providers/lookup.py b/home_library/providers/lookup.py index f9dbbec..8e5c128 100644 --- a/home_library/providers/lookup.py +++ b/home_library/providers/lookup.py @@ -1,7 +1,8 @@ """Provider orchestration for Home Library. Координирует поиск по ISBN через несколько провайдеров с fallback-логикой: -Labirint → Piter → Google Books (параллельно), затем DuckDuckGo. +Labirint → Piter → Google Books → Yandex (опционально) параллельно, +затем Playwright-поиск по книжным сайтам и DuckDuckGo. """ import asyncio @@ -14,6 +15,11 @@ from home_library.providers.google_books import fetch_from_google_books from home_library.providers.labirint import fetch_from_labirint from home_library.providers.piter import fetch_from_piter +from home_library.providers.playwright_site_search import ( + fetch_from_playwright_site_search, +) +from home_library.providers.rsl_rkp import fetch_from_rsl_rkp +from home_library.providers.yandex import fetch_from_yandex logger = logging.getLogger(__name__) @@ -41,10 +47,16 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None: logger.info("Начинаю поиск книги по ISBN %s", isbn) # Порядок приоритета: результат первого по индексу побеждает. + # РГБ — национальный депозитарий, для префикса 978-5-… покрывает + # больше, чем любой одиночный коммерческий каталог. Яндекс подключён + # опционально через env YANDEX_ENABLED=1 — без флага провайдер + # мгновенно возвращает None и не замедляет гонку. primary_providers = [ + ("РГБ", fetch_from_rsl_rkp), ("Лабиринт", fetch_from_labirint), ("Piter", fetch_from_piter), ("Google Books", fetch_from_google_books), + ("Яндекс", fetch_from_yandex), ] async with httpx.AsyncClient(timeout=10) as client: @@ -63,8 +75,14 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None: logger.info("ISBN %s найден через %s: %r", isbn, name, result.title) return result + logger.info("Основные провайдеры не нашли ISBN %s, пробую Playwright site search", isbn) + book = await fetch_from_playwright_site_search(isbn, client) + if book: + logger.info("ISBN %s найден через Playwright site search: %r", isbn, book.title) + return book + # DuckDuckGo — менее надёжный fallback, запускаем отдельно - logger.info("Основные провайдеры не нашли ISBN %s, пробую DuckDuckGo", isbn) + logger.info("Playwright site search не нашел ISBN %s, пробую DuckDuckGo", isbn) book = await fetch_from_ddg(isbn, client) if book: logger.info("ISBN %s найден через DuckDuckGo: %r", isbn, book.title) diff --git a/home_library/providers/playwright_site_search.py b/home_library/providers/playwright_site_search.py new file mode 100644 index 0000000..f30700e --- /dev/null +++ b/home_library/providers/playwright_site_search.py @@ -0,0 +1,270 @@ +"""Playwright fallback for site-specific ISBN search. + +Провайдер ходит напрямую на книжные сайты с поиском по ISBN через +Playwright и вытаскивает книгу из серверного HTML без зависимости от +поисковых движков. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import html +import logging +import os +import re +from typing import TYPE_CHECKING + +from home_library.domain.models import BookRecord +from home_library.providers.labirint import _parse_labirint_book_page +from home_library.providers.yandex import YANDEX_STEALTH_SETTLE_SECONDS, YANDEX_USER_AGENT + +if TYPE_CHECKING: + import httpx + +logger = logging.getLogger(__name__) + +PLAYWRIGHT_SITE_SEARCH_ENABLED_ENV = "PLAYWRIGHT_SITE_SEARCH_ENABLED" +PLAYWRIGHT_SITE_TIMEOUT_MS = 20_000 + +LIVELIB_SEARCH_URL = "https://www.livelib.ru/find/{isbn}" +MYBOOK_SEARCH_URL = "https://mybook.ru/search/books/?q={isbn}" +LABIRINT_SEARCH_URL = "https://www.labirint.ru/search/{isbn}/" + +BOOK_LINK_PATTERN = re.compile(r'href="(?P<href>[^"]+)"') +LIVELIB_RESULT_PATTERN = re.compile( + r'href="(?P<href>/book/[^"]+)"[^>]*title="(?P<full>[^"]+)"[^>]*>' + r"(?P<title>[^<]+)</a>.*?" + r'href="(?P<author_href>/author/[^"]+)"[^>]*title="(?P<author>[^<]+)"', + re.DOTALL, +) +MYBOOK_RESULT_PATTERN = re.compile( + r'href="(?P<href>/author/[^"#?]+/[^"#?]+/)"[^>]*>.*?' + r"<p[^>]*>(?P<title>[^<]+?)</p>.*?" + r'href="(?P<author_href>/author/[^"#?]+/)"[^>]*>.*?' + r"<div[^>]*>(?P<author>[^<]+?)</div>", + re.DOTALL, +) +LABIRINT_BOOK_URL_PATTERN = re.compile(r'href="(?P<href>/books/\d+/)"') +LABIRINT_RESULT_PATTERN = re.compile( + r'(?:class="product-title-link"[^>]*href|href)="(?P<href>/books/\d+/)"[^>]*>\s*(?P<title>[^<]+?)\s*</a>.*?' + r'href="/authors/\d+/"[^>]*>\s*(?:<span>)?(?P<author>[^<]+?)(?:</span>)?\s*</a>.*?' + r'href="/pubhouse/\d+/"[^>]*>\s*(?P<publisher>[^<]+?)\s*</a>', + re.DOTALL, +) + + +def _clean_html_text(value: str) -> str: + """Нормализует текст, извлечённый из HTML.""" + normalized = html.unescape(value) + normalized = re.sub(r"\s+", " ", normalized) + return normalized.strip(" \n\t\r\"'«»") + + +def _normalize_href(href: str, base_url: str) -> str: + """Преобразует относительную ссылку в абсолютную.""" + if href.startswith("http"): + return href + return f"{base_url.rstrip('/')}{href}" + + +def _build_book_record( + *, + title: str, + author: str, + isbn: str, + link: str, + publisher: str = "", +) -> BookRecord | None: + """Собирает ``BookRecord`` из сырых полей сайта.""" + cleaned_title = _clean_html_text(title) + cleaned_author = _clean_html_text(author) + cleaned_publisher = _clean_html_text(publisher) + if not cleaned_title: + return None + + return BookRecord( + title=cleaned_title, + author=cleaned_author, + publisher=cleaned_publisher, + topics="", + link=link, + isbn=isbn, + ) + + +def _parse_livelib_search_page(text: str, isbn: str) -> BookRecord | None: + """Извлекает первую книгу из HTML LiveLib поиска по ISBN.""" + match = LIVELIB_RESULT_PATTERN.search(text) + if not match: + return None + + return _build_book_record( + title=match.group("title") or match.group("full"), + author=match.group("author"), + isbn=isbn, + link=_normalize_href(match.group("href"), "https://www.livelib.ru"), + ) + + +def _parse_mybook_search_page(text: str, isbn: str) -> BookRecord | None: + """Извлекает первую книгу из HTML MyBook поиска по ISBN.""" + match = MYBOOK_RESULT_PATTERN.search(text) + if not match: + return None + + return _build_book_record( + title=match.group("title"), + author=match.group("author"), + isbn=isbn, + link=_normalize_href(match.group("href"), "https://mybook.ru"), + ) + + +def _extract_labirint_book_url(text: str) -> str | None: + """Вытаскивает URL первой карточки книги из поиска Лабиринта.""" + match = LABIRINT_BOOK_URL_PATTERN.search(text) + if not match: + return None + return _normalize_href(match.group("href"), "https://www.labirint.ru") + + +def _parse_labirint_search_page(text: str, isbn: str) -> BookRecord | None: + """Извлекает первую книгу из HTML поиска Лабиринта.""" + match = LABIRINT_RESULT_PATTERN.search(text) + if not match: + return None + + return _build_book_record( + title=match.group("title"), + author=match.group("author"), + publisher=match.group("publisher"), + isbn=isbn, + link=_normalize_href(match.group("href"), "https://www.labirint.ru"), + ) + + +async def _open_page(context, url: str) -> str | None: + """Открывает страницу через Playwright и возвращает её HTML.""" + from playwright.async_api import Error as PlaywrightError # noqa: PLC0415 + from playwright.async_api import TimeoutError as PlaywrightTimeoutError # noqa: PLC0415 + + page = await context.new_page() + try: + await page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_SITE_TIMEOUT_MS) + with contextlib.suppress(PlaywrightTimeoutError): + await page.wait_for_load_state("networkidle", timeout=PLAYWRIGHT_SITE_TIMEOUT_MS) + await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS) + return await page.content() + except PlaywrightError as exc: + logger.warning( + "Playwright site search: ошибка загрузки %s: %s — %s", + url, + type(exc).__name__, + exc, + ) + return None + finally: + await page.close() + + +async def _fetch_from_livelib(context, isbn: str) -> BookRecord | None: + """Ищет книгу по ISBN на LiveLib.""" + text = await _open_page(context, LIVELIB_SEARCH_URL.format(isbn=isbn)) + if text is None: + return None + return _parse_livelib_search_page(text, isbn) + + +async def _fetch_from_mybook(context, isbn: str) -> BookRecord | None: + """Ищет книгу по ISBN на MyBook.""" + text = await _open_page(context, MYBOOK_SEARCH_URL.format(isbn=isbn)) + if text is None: + return None + return _parse_mybook_search_page(text, isbn) + + +async def _fetch_from_labirint_playwright(context, isbn: str) -> BookRecord | None: + """Ищет книгу по ISBN на Лабиринте через Playwright.""" + search_html = await _open_page(context, LABIRINT_SEARCH_URL.format(isbn=isbn)) + if search_html is None: + return None + + if book := _parse_labirint_search_page(search_html, isbn): + return book + + book_url = _extract_labirint_book_url(search_html) + if book_url is None: + return None + + book_html = await _open_page(context, book_url) + if book_html is None: + return None + + return _parse_labirint_book_page(book_html, book_url, isbn) + + +async def fetch_from_playwright_site_search( + isbn: str, + _client: httpx.AsyncClient, +) -> BookRecord | None: + """Последний Playwright fallback по книжным сайтам. + + Провайдер включается только при ``PLAYWRIGHT_SITE_SEARCH_ENABLED=1``. + Сначала проверяет LiveLib и MyBook, затем отдельно повторяет + Labirint через браузерный контекст. + """ + if os.environ.get(PLAYWRIGHT_SITE_SEARCH_ENABLED_ENV) != "1": + return None + + try: + from playwright.async_api import async_playwright # noqa: PLC0415 + from playwright_stealth import Stealth # noqa: PLC0415 + except ImportError as exc: + logger.warning( + "PLAYWRIGHT_SITE_SEARCH_ENABLED=1, но не установлен playwright/playwright-stealth: %s", + exc, + ) + return None + + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled"], + ) + try: + context = await browser.new_context( + user_agent=YANDEX_USER_AGENT, + locale="ru-RU", + timezone_id="Europe/Moscow", + viewport={"width": 1280, "height": 900}, + ) + await Stealth().apply_stealth_async(context) + + for site_name, provider in ( + ("LiveLib", _fetch_from_livelib), + ("MyBook", _fetch_from_mybook), + ("Лабиринт (Playwright)", _fetch_from_labirint_playwright), + ): + book = await provider(context, isbn) + if book is not None: + logger.info( + "ISBN %s найден через %s Playwright fallback: %r", + isbn, + site_name, + book.title, + ) + return book + finally: + await browser.close() + except Exception as exc: # noqa: BLE001 + logger.warning( + "Playwright site search: ошибка поиска ISBN %s: %s — %s", + isbn, + type(exc).__name__, + exc, + ) + return None + + return None diff --git a/home_library/providers/rsl_rkp.py b/home_library/providers/rsl_rkp.py new file mode 100644 index 0000000..7afc1a2 --- /dev/null +++ b/home_library/providers/rsl_rkp.py @@ -0,0 +1,296 @@ +"""Российская государственная библиотека (РКП) provider adapter. + +Ищет книгу по ISBN через каталог `search.rsl.ru`. РГБ — национальный +депозитарий, и при префиксе ISBN ``978-5-…`` обеспечивает самое +полное покрытие в ру-сегменте по сравнению со всеми остальными +источниками. Endpoint требует CSRF-токен и cookies, которые +выдаются одним bootstrap-`GET`. + +Поведение управляется флагом ``RSL_RKP_ENABLED``: значение ``"0"`` +выключает провайдера. Любое другое значение или отсутствие +переменной — провайдер активен по умолчанию (CSRF-bootstrap дешёвый, +сайт не требует Playwright). +""" + +from __future__ import annotations + +import html +import logging +import os +import re +from http import HTTPStatus + +import httpx + +from home_library import config +from home_library.domain.models import BookRecord + +logger = logging.getLogger(__name__) + +CSRF_META_PATTERN = re.compile(r'name="csrf-token"\s+content="([^"]+)"') +SEARCH_CONTAINER_PATTERN = re.compile( + r'<div\s+class="row\s+search-container[^"]*"\s+data-id="(?P<record_id>\d+)"' + r"(?P<body>.*?)" + r"(?=<div\s+class=\"row\s+search-container|<div\s+id=\"page-separator|</div>\s*</div>\s*</div>\s*</div>\s*$)", + re.DOTALL, +) +MAIN_INFO_PATTERN = re.compile( + r'class="js-item-maininfo"[^>]*>(?P<value>.*?)</span>', + re.DOTALL, +) +AUTHOR_INFO_PATTERN = re.compile( + r'class="js-item-authorinfo"[^>]*>(?P<value>.*?)</b>', + re.DOTALL, +) +TOPIC_PATTERN = re.compile( + r'rsl-item-otherinfo-item-name">\s*Тема\s*</div>\s*' + r'<div\s+class="rsl-item-otherinfo-item-value">(?P<value>.*?)</div>', + re.DOTALL, +) + +MATERIAL_MARKER_PATTERN = re.compile(r"\[(?:Текст|Электронный\s+ресурс|Изоматериал|Видео|Звукозапись)\]") +AGE_RATING_PATTERN = re.compile(r"\[\d+\+\]") +SQUARE_BRACKET_NOTE_PATTERN = re.compile(r"\[[^\[\]]+\]") +COPYRIGHT_PREFIX_PATTERN = re.compile(r"\bcop\.\s*", re.IGNORECASE) + + +def _strip_html(text: str) -> str: + """Удаляет HTML-теги и нормализует пробелы.""" + no_tags = re.sub(r"<[^>]+>", "", text) + return re.sub(r"\s+", " ", html.unescape(no_tags)).strip() + + +def _normalize_segment(segment: str) -> str: + """Чистит сегмент библиографического описания.""" + cleaned = MATERIAL_MARKER_PATTERN.sub("", segment) + cleaned = AGE_RATING_PATTERN.sub("", cleaned) + cleaned = COPYRIGHT_PREFIX_PATTERN.sub("", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned.strip(" .,:;\"'«»") + + +def parse_bibliographic_description(text: str) -> dict[str, str]: + """Разбирает строку описания книги в формате ГОСТ 7.1. + + Args: + text: исходная строка с библиографическим описанием РГБ. + + Returns: + Словарь с ключами ``title``, ``author``, ``publisher``, ``year``, + ``series``. Отсутствующее поле — пустая строка. + + Example:: + + >>> parse_bibliographic_description( + ... "Никто не заплачет : [16+] / Полина Дашкова. - " + ... "Москва : АСТ, cop. 2020. - 411 с." + ... )["author"] + 'Полина Дашкова' + """ + if not text: + return {"title": "", "author": "", "publisher": "", "year": "", "series": ""} + + normalized = re.sub(r"\s+", " ", text).strip() + + # Серия — последний блок в круглых скобках после ". - " + series = "" + series_match = re.search(r"\.\s*-\s*\(([^()]+)\)", normalized) + if series_match: + series = _normalize_segment(series_match.group(1)) + + # Откидываем хвост от первого ". - " (это импринт + физ. характеристики + серия) + head_tail = re.split(r"\.\s*-\s*", normalized, maxsplit=1) + head = head_tail[0] + tail = head_tail[1] if len(head_tail) > 1 else "" + + # Title / authors из head: до '/' — title, после — авторы (до ';' или конца) + if "/" in head: + title_part, author_part = head.split("/", 1) + else: + title_part, author_part = head, "" + + # Если title содержит ' : ' — берём до первого подзаголовка-в-кавычках/возрастного маркера + title_clean = title_part + title_clean = re.sub(r"\s*:\s*\[[^\[\]]+\].*$", "", title_clean) + title_clean = re.sub(r"\s*:\s*[^/]*$", "", title_clean) if " : " in title_clean else title_clean + title = _normalize_segment(title_clean) + + # Author: всё до первого ';' (там обычно идут переводчики/иллюстраторы) + author_main = author_part.split(";", 1)[0] + author = _normalize_segment(author_main) + + publisher = "" + year = "" + if tail: + imprint = tail + imprint = re.split(r"\.\s*-\s*", imprint, maxsplit=1)[0] + imprint = COPYRIGHT_PREFIX_PATTERN.sub("", imprint) + match = re.match( + r"\s*(?P<city>[^:,]+?)\s*:\s*(?P<publisher>.+?)\s*,\s*(?P<year>\d{4})\s*\.?", + imprint, + ) + if match: + publisher = _normalize_segment(match.group("publisher")) + year = match.group("year") + else: + # Без года: "<Город> : <Издатель>." + no_year_match = re.match(r"\s*(?P<city>[^:,]+?)\s*:\s*(?P<publisher>[^.,]+?)\s*\.?\s*$", imprint) + if no_year_match: + publisher = _normalize_segment(no_year_match.group("publisher")) + + return { + "title": title, + "author": author, + "publisher": publisher, + "year": year, + "series": series, + } + + +def parse_search_content(content_html: str) -> list[dict[str, str]]: + """Извлекает карточки книг из поля ``content`` ответа ``ajax-search``. + + Args: + content_html: HTML-фрагмент из JSON-ответа РГБ. + + Returns: + Список словарей по каждой найденной карточке с ключами + ``record_id``, ``main_info``, ``author_info``, ``topic``. + """ + if not content_html: + return [] + + cards: list[dict[str, str]] = [] + for match in SEARCH_CONTAINER_PATTERN.finditer(content_html): + record_id = match.group("record_id") + body = match.group("body") + main_match = MAIN_INFO_PATTERN.search(body) + author_match = AUTHOR_INFO_PATTERN.search(body) + topic_match = TOPIC_PATTERN.search(body) + cards.append( + { + "record_id": record_id, + "main_info": _strip_html(main_match.group("value")) if main_match else "", + "author_info": _strip_html(author_match.group("value")) if author_match else "", + "topic": _strip_html(topic_match.group("value")) if topic_match else "", + }, + ) + return cards + + +def _is_enabled() -> bool: + """Возвращает ``True``, когда провайдер не выключен через env.""" + return os.environ.get(config.RSL_RKP_ENABLED_ENV, "1") != "0" + + +async def _bootstrap_csrf(client: httpx.AsyncClient) -> str | None: + """Получает CSRF-токен и расставляет session cookies для дальнейшего поиска.""" + bootstrap_url = config.RSL_RKP_BASE_URL + config.RSL_RKP_BOOTSTRAP_PATH + resp = await client.get(bootstrap_url, follow_redirects=True) + if resp.status_code != HTTPStatus.OK: + return None + match = CSRF_META_PATTERN.search(resp.text) + return match.group(1) if match else None + + +def _build_search_payload(isbn: str) -> dict[str, str | list[str]]: + return { + "SearchFilterForm[elfunds]": "0", + "SearchFilterForm[nofile]": "0", + "SearchFilterForm[accessFree]": "0", + "SearchFilterForm[accessLimited]": "0", + "SearchFilterForm[pubyearfrom]": "", + "SearchFilterForm[pubyearto]": "", + "SearchFilterForm[digitizedDateFrom]": "", + "SearchFilterForm[digitizedDateTo]": "", + "SearchFilterForm[enterdatefrom]": "", + "SearchFilterForm[enterdateto]": "", + "SearchFilterForm[sortby]": "default", + "SearchFilterForm[page]": "1", + "SearchFilterForm[inDodRoom]": "0", + "SearchFilterForm[search]": isbn, + "SearchFilterForm[fulltext]": "0", + "SearchFilterForm[updatedFields][]": "search", + } + + +async def _query_rsl( + isbn: str, + client: httpx.AsyncClient, +) -> dict[str, object] | None: + """Делает bootstrap-GET и POST к ajax-search; возвращает dict или ``None``.""" + try: + csrf = await _bootstrap_csrf(client) + except (httpx.HTTPError, OSError) as exc: + logger.warning("РГБ: bootstrap упал по ISBN %s: %s — %s", isbn, type(exc).__name__, exc) + return None + if not csrf: + logger.warning("РГБ: CSRF-токен не получен для ISBN %s", isbn) + return None + + search_url = config.RSL_RKP_BASE_URL + config.RSL_RKP_AJAX_SEARCH_PATH + try: + resp = await client.post( + search_url, + data=_build_search_payload(isbn), + headers={ + "X-CSRF-Token": csrf, + "X-Requested-With": "XMLHttpRequest", + "Referer": config.RSL_RKP_BASE_URL + "/ru/search", + "Accept": "application/json, text/javascript, */*; q=0.01", + }, + ) + except (httpx.HTTPError, OSError) as exc: + logger.warning("РГБ: сеть упала по ISBN %s: %s — %s", isbn, type(exc).__name__, exc) + return None + + if resp.status_code != HTTPStatus.OK: + logger.warning("РГБ: HTTP %s по ISBN %s", resp.status_code, isbn) + return None + + try: + payload = resp.json() + except ValueError as exc: + logger.warning("РГБ: невалидный JSON по ISBN %s: %s", isbn, exc) + return None + return payload if isinstance(payload, dict) else None + + +async def fetch_from_rsl_rkp(isbn: str, client: httpx.AsyncClient) -> BookRecord | None: + """Ищет книгу в каталоге РГБ по ISBN. + + Args: + isbn: ISBN-код книги (10 или 13 цифр). + client: Async HTTP-клиент (``httpx.AsyncClient``). + + Returns: + ``BookRecord`` с найденными метаданными или ``None``, если книга + не найдена или провайдер выключен. + """ + if not _is_enabled(): + return None + + payload = await _query_rsl(isbn, client) + if payload is None: + return None + if not payload.get("TotalHits"): + return None + + cards = parse_search_content(str(payload.get("content") or "")) + if not cards: + return None + + card = cards[0] + parsed = parse_bibliographic_description(card["main_info"]) + title = parsed["title"] + if not title: + return None + + return BookRecord( + title=title, + author=parsed["author"] or card["author_info"].rstrip("."), + publisher=parsed["publisher"], + topics=card["topic"], + link=config.RSL_RKP_RECORD_URL_TEMPLATE.format(record_id=card["record_id"]), + isbn=isbn, + ) diff --git a/home_library/providers/title_page.py b/home_library/providers/title_page.py new file mode 100644 index 0000000..2f7246d --- /dev/null +++ b/home_library/providers/title_page.py @@ -0,0 +1,638 @@ +"""OCR и эвристики для оборота титульного листа книги.""" + +import logging +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError + +from home_library import config + +logger = logging.getLogger(__name__) + +ISBN_PATTERN = re.compile( + r"(?i)\bisbn(?:-1[03])?[:\s]*([0-9xX\-\s]{10,20})", +) +LINE_SPACE_PATTERN = re.compile(r"\s+") +CONTROL_CODE_PATTERN = re.compile(r"^[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*$", re.IGNORECASE) +CONTROL_CODE_PREFIX_PATTERN = re.compile(r"^(?:[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*)\s+", re.IGNORECASE) +TRAILING_FRAGMENT_PENALTY = 3 +TRAILING_FRAGMENT_MAX_LETTERS = 4 +NOISE_PREFIXES = ("ббк", "bbk", "удк", "udk", "©", "copyright", "права", "информация", "интервью", "глава") +NOISE_MIN_LETTERS = 4 +AUTHOR_MIN_WORDS = 2 +AUTHOR_MAX_LENGTH = 60 +OCR_FALLBACK_THRESHOLD = 170 +OCR_BOTTOM_CROPS = (0.5, 0.66) +OCR_TARGET_MAX_EDGE = 2400 +OCR_PSMS = ("3", "4", "6", "11") +OCR_TESSERACT_DPI = "300" +OCR_TESSERACT_OEM = "1" +AUTHOR_MIN_LETTER_RATIO = 0.55 +TITLE_MIN_LETTER_RATIO = 0.45 +SCORING_AUTHOR_MIN_WORDS = 2 +SCORING_AUTHOR_MAX_WORDS = 5 +SCORING_TITLE_MIN_LENGTH = 15 +SCORING_TITLE_MAX_LENGTH = 160 +SENTENCE_TERMINATORS = (".", ")", "!", "?", "”", "»") +JUNK_TOKEN_MAX_LETTERS = 2 +MIN_TERMINATORS_FOR_SEGMENT_STRIP = 2 +TAIL_SPLIT_PARTS = 2 +HEAD_SPLIT_PARTS = 2 +TRAILING_LAST_WORD_MAX_LETTERS = 1 +OCR_SCALE_NOOP_LOWER = 0.95 +OCR_SCALE_NOOP_UPPER = 1.05 +HYPHEN_LINE_END_PATTERN = re.compile(r"(.*\S)[-‐‑‒–—]\s*$") +LOWER_LETTER_START_PATTERN = re.compile(r"^[\sа-яёa-z]") +NON_WORD_PATTERN = re.compile(r"[^\w]", re.UNICODE) +YEAR_PATTERN = re.compile(r"\b(?:19|20)\d{2}\b") +PUBLISHING_MARKERS = ( + "спб", + "спб.", + "м.", + "москва", + "санкт-петербург", + "питер", + "изд", + "издательство", + "эксмо", + "ast", + "act", + "аст", + "альпина", + "манн", +) + + +@dataclass(slots=True) +class ParsedTitlePage: + """Результат OCR-разбора оборота титульного листа.""" + + isbn: str = "" + author: str = "" + title: str = "" + raw_text: str = "" + + +def _normalize_line(line: str) -> str: + """Очищает OCR-строку от лишних пробелов.""" + return LINE_SPACE_PATTERN.sub(" ", line.replace("\u00a0", " ")).strip() + + +def _is_junk_tail_token(token: str) -> bool: + """Определяет, выглядит ли токен как короткий OCR-мусор после конца предложения.""" + stripped = NON_WORD_PATTERN.sub("", token) + if not stripped: + return True + if any(char.isdigit() for char in stripped): + return False + return len(stripped) <= JUNK_TOKEN_MAX_LETTERS + + +def _strip_after_last_terminator(line: str) -> str | None: + """Если после последнего терминатора стоит мусор, обрезает его. Иначе возвращает ``None``.""" + positions = [index for index, char in enumerate(line) if char in SENTENCE_TERMINATORS] + if not positions: + return None + last_pos = positions[-1] + tail = line[last_pos + 1 :].strip() + if tail: + tokens = tail.split() + if tokens and all(_is_junk_tail_token(token) for token in tokens): + return line[: last_pos + 1].rstrip() + return None + if len(positions) >= MIN_TERMINATORS_FOR_SEGMENT_STRIP: + prev_pos = positions[-2] + segment_tokens = line[prev_pos + 1 : last_pos].split() + if segment_tokens and all(_is_junk_tail_token(token) for token in segment_tokens): + return line[: prev_pos + 1].rstrip() + return None + + +def _strip_trailing_short_word(line: str) -> str | None: + """Срезает однобуквенный или не-словесный хвостовой токен. Иначе возвращает ``None``.""" + tail_split = line.rsplit(maxsplit=1) + if len(tail_split) != TAIL_SPLIT_PARTS: + return None + last_word = NON_WORD_PATTERN.sub("", tail_split[1]) + if any(char.isdigit() for char in last_word): + return None + if not last_word or len(last_word) <= TRAILING_LAST_WORD_MAX_LETTERS: + return tail_split[0].rstrip() + return None + + +def _strip_ocr_tail_noise(line: str) -> str: + """Срезает короткий OCR-мусор с хвоста строки. + + Применяет в цикле две стратегии: срез после последнего терминатора и срез + одиночных коротких хвостовых токенов. + """ + line = line.rstrip() + while True: + stripped = _strip_after_last_terminator(line) + if stripped is None: + stripped = _strip_trailing_short_word(line) + if stripped is None: + return line + line = stripped + + +def _strip_ocr_lead_noise(line: str) -> str: + """Срезает однобуквенный OCR-мусор в начале строки.""" + while True: + head_split = line.lstrip().split(maxsplit=1) + if len(head_split) < HEAD_SPLIT_PARTS: + return line.lstrip() + first_word = NON_WORD_PATTERN.sub("", head_split[0]) + if not first_word: + line = head_split[1] + continue + if any(char.isdigit() for char in first_word): + return line.lstrip() + if len(first_word) <= 1: + line = head_split[1] + continue + return line.lstrip() + + +def _join_hyphenated_lines(lines: list[str]) -> list[str]: + """Склеивает строки, где предыдущая оканчивается на дефис, а следующая — на строчную букву.""" + if not lines: + return lines + result: list[str] = [] + skip_next = False + for index, current in enumerate(lines): + if skip_next: + skip_next = False + continue + match = HYPHEN_LINE_END_PATTERN.match(current) + if match and index + 1 < len(lines) and LOWER_LETTER_START_PATTERN.match(lines[index + 1]): + joined = match.group(1) + lines[index + 1].lstrip() + result.append(joined) + skip_next = True + continue + result.append(current) + return result + + +def _strip_control_code_prefix(line: str) -> str: + """Убирает библиотечный шифр из начала строки, если он есть.""" + return CONTROL_CODE_PREFIX_PATTERN.sub("", line).strip() + + +def _contains_letters(value: str) -> bool: + """Проверяет, есть ли в строке буквы.""" + return any(char.isalpha() for char in value) + + +def _letter_ratio(value: str) -> float: + """Возвращает долю букв среди значимых символов строки.""" + significant_chars = [char for char in value if not char.isspace()] + if not significant_chars: + return 0.0 + letters = sum(char.isalpha() for char in significant_chars) + return letters / len(significant_chars) + + +def _contains_cyrillic(value: str) -> bool: + """Проверяет, есть ли в строке кириллица.""" + return any("а" <= char.lower() <= "я" or char.lower() == "ё" for char in value) + + +def _normalize_isbn(candidate: str) -> str: + """Нормализует ISBN-кандидат до строки без дефисов и пробелов.""" + value = re.sub(r"[^0-9Xx]", "", candidate) + if len(value) == config.ISBN13_LENGTH and value.startswith(("978", "979")): + return value + if len(value) == config.ISBN10_LENGTH and (value.isdigit() or (value[:-1].isdigit() and value[-1] in "Xx")): + return value.upper() + return "" + + +def extract_isbn_from_text(text: str) -> str: + """Извлекает первый валидный ISBN из OCR-текста.""" + for match in ISBN_PATTERN.finditer(text): + normalized = _normalize_isbn(match.group(1)) + if normalized: + return normalized + + generic_candidates = re.findall(r"(?:97[89][\-\s]*)?(?:\d[\-\s]*){9,12}[\dXx]", text) + for candidate in generic_candidates: + normalized = _normalize_isbn(candidate) + if normalized: + return normalized + return "" + + +def _looks_like_noise(line: str) -> bool: + """Определяет, относится ли строка к шуму, а не к библиографическим данным.""" + lower = line.lower() + if not lower: + return True + if lower.startswith(NOISE_PREFIXES): + return True + if CONTROL_CODE_PATTERN.match(line): + return True + if lower in {"isbn", "автор", "название"}: + return True + letters = sum(1 for char in line if char.isalpha()) + has_digit = any(char.isdigit() for char in line) + return letters < NOISE_MIN_LETTERS and not has_digit + + +def _strip_publishing_tail(line: str) -> str: + """Отрезает хвост с издательскими данными от строки заглавия.""" + chunks = re.split(r"\s+[—-]\s+", line) + kept: list[str] = [] + for chunk in chunks: + lower = chunk.lower() + if any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(lower): + break + kept.append(chunk) + if kept: + return " — ".join(kept).strip(" .") + return line.strip(" .") + + +def _looks_like_author_line(line: str) -> bool: + """Проверяет, похожа ли строка на имя автора.""" + stripped = _strip_control_code_prefix(line) + return ( + not any(char.isdigit() for char in stripped) and AUTHOR_MIN_WORDS <= len(stripped.split()) <= AUTHOR_MAX_LENGTH + ) + + +def _extract_title_candidate(line: str) -> str: + """Очищает строку заглавия от шифра, автора и издательского хвоста.""" + cleaned = _strip_control_code_prefix(line) + title_part = cleaned.split("/", 1)[0] + return re.sub(r"\s+", " ", _strip_publishing_tail(title_part)).strip(" .") + + +def _looks_like_bibliographic_line(line: str) -> bool: + """Определяет, похожа ли строка на библиографическое описание рядом с ISBN.""" + stripped = _strip_control_code_prefix(line) + lower = stripped.lower() + return "/" in stripped and ( + any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(stripped) is not None + ) + + +def _extract_from_bibliographic_lines(section: list[str]) -> tuple[str, str]: + """Ищет автора и название в библиографической строке рядом с ISBN.""" + for index in range(len(section) - 1, -1, -1): + line = section[index] + if "/" not in line: + continue + + bibliographic_line = next( + ( + candidate + for candidate in _bibliographic_candidates(section, index) + if _looks_like_bibliographic_line(candidate) + ), + None, + ) + if bibliographic_line is None: + continue + + title = _extract_title_candidate(bibliographic_line) + if not title: + continue + + for candidate in reversed(section[max(0, index - 3) : index]): + normalized_candidate = _strip_control_code_prefix(candidate).strip(" .") + if _looks_like_author_line(normalized_candidate): + return normalized_candidate, title + return "", title + return "", "" + + +def _bibliographic_candidates(section: list[str], index: int) -> list[str]: + """Возвращает варианты строк для проверки на библиографическое описание.""" + candidates = [section[index]] + if index + 1 < len(section): + candidates.append(" ".join(section[index : index + 2])) + if index + 2 < len(section): + candidates.append(" ".join(section[index : index + 3])) + return candidates + + +def _collect_meaningful_lines(raw_text: str) -> tuple[list[str], list[str]]: + """Возвращает значимые строки и их подмножество до строки с ISBN.""" + normalized = [_normalize_line(line) for line in raw_text.splitlines()] + non_noise = [line for line in normalized if line and not _looks_like_noise(line)] + cleaned = [_strip_ocr_tail_noise(_strip_ocr_lead_noise(line)) for line in non_noise] + joined = _join_hyphenated_lines([line for line in cleaned if line]) + meaningful_lines = [line for line in joined if not _looks_like_noise(line)] + lines_before_isbn: list[str] = [] + for line in meaningful_lines: + if "isbn" in line.lower(): + break + lines_before_isbn.append(line) + return meaningful_lines, lines_before_isbn + + +def _extract_author_and_title(section: list[str]) -> tuple[str, str]: + """Пытается извлечь автора и название из верхнего блока OCR-строк.""" + bibliographic_author, bibliographic_title = _extract_from_bibliographic_lines(section) + if bibliographic_title: + return bibliographic_author, bibliographic_title + + author = "" + title = "" + + for index, line in enumerate(section): + if not _looks_like_author_line(line): + continue + author = _strip_control_code_prefix(line).strip(" .") + tail_lines = [_strip_control_code_prefix(item) for item in section[index + 1 : index + 4]] + if tail_lines: + title = _extract_title_candidate(" ".join(tail_lines)) + break + + if not title: + title_candidates = [ + _extract_title_candidate(line) for line in section if _strip_control_code_prefix(line).strip(" .") != author + ] + title_candidates = [line for line in title_candidates if line] + if title_candidates: + title = title_candidates[0] + return author, re.sub(r"\s+", " ", title).strip(" .") + + +def _is_low_quality_author(author: str) -> bool: + """Отбрасывает явный OCR-мусор в поле автора.""" + lower = author.lower() + if not author or not _contains_letters(author): + return True + if "_" in author or " or " in lower: + return True + return _letter_ratio(author) < AUTHOR_MIN_LETTER_RATIO + + +def _is_low_quality_title(title: str) -> bool: + """Отбрасывает явный OCR-мусор в поле названия.""" + lower = title.lower() + if not title or not _contains_letters(title): + return True + if "_" in title or any(marker in lower for marker in ("isbn", "удк", "ббк")): + return True + return _letter_ratio(title) < TITLE_MIN_LETTER_RATIO + + +def _sanitize_parsed_title_page(parsed: ParsedTitlePage) -> ParsedTitlePage: + """Очищает распознанные поля от низкокачественного OCR-мусора.""" + author = "" if _is_low_quality_author(parsed.author) else parsed.author + title = "" if _is_low_quality_title(parsed.title) else parsed.title + if author == parsed.author and title == parsed.title: + return parsed + return ParsedTitlePage(isbn=parsed.isbn, author=author, title=title, raw_text=parsed.raw_text) + + +def _trailing_fragment_penalty(text: str) -> int: + """Штраф за хвостовой OCR-фрагмент: короткое последнее слово, начинающееся со строчной буквы.""" + parts = text.rsplit(maxsplit=1) + if len(parts) < TAIL_SPLIT_PARTS: + return 0 + last_word = NON_WORD_PATTERN.sub("", parts[1]) + if not last_word: + return 0 + if any(char.isdigit() for char in last_word): + return 0 + if len(last_word) <= TRAILING_FRAGMENT_MAX_LETTERS and last_word[0].islower(): + return TRAILING_FRAGMENT_PENALTY + return 0 + + +def _score_parsed_title_page(parsed: ParsedTitlePage) -> int: + """Оценивает качество распознанного кандидата для выбора лучшего OCR-прохода.""" + score = 0 + if parsed.isbn: + score += 10 + if parsed.author: + score += 8 + if _contains_cyrillic(parsed.author): + score += 2 + if SCORING_AUTHOR_MIN_WORDS <= len(parsed.author.split()) <= SCORING_AUTHOR_MAX_WORDS: + score += 1 + score -= _trailing_fragment_penalty(parsed.author) + if parsed.title: + score += 8 + if _contains_cyrillic(parsed.title): + score += 2 + if SCORING_TITLE_MIN_LENGTH <= len(parsed.title) <= SCORING_TITLE_MAX_LENGTH: + score += 2 + score -= _trailing_fragment_penalty(parsed.title) + return score + + +def _select_best_parsed_title_page(text_candidates: list[str]) -> ParsedTitlePage | None: + """Выбирает лучший распознанный результат из нескольких OCR-кандидатов.""" + best_parsed: ParsedTitlePage | None = None + best_score = -1 + for candidate in text_candidates: + parsed = parse_title_page_text(candidate) + if parsed is None: + continue + sanitized = _sanitize_parsed_title_page(parsed) + score = _score_parsed_title_page(sanitized) + if score > best_score: + best_parsed = sanitized + best_score = score + return best_parsed + + +def parse_title_page_text(text: str) -> ParsedTitlePage | None: + """Пытается извлечь ISBN, автора и название из OCR-текста.""" + raw_text = text.strip() + if not raw_text: + return None + + isbn = extract_isbn_from_text(raw_text) + meaningful_lines, lines_before_isbn = _collect_meaningful_lines(raw_text) + if not meaningful_lines and not isbn: + return None + + section = lines_before_isbn or meaningful_lines[:6] + author, title = _extract_author_and_title(section) + author = re.sub(r"\s+", " ", author).strip(" .") + + if not any((isbn, author, title)): + return None + return _sanitize_parsed_title_page(ParsedTitlePage(isbn=isbn, author=author, title=title, raw_text=raw_text)) + + +def _open_image(image_bytes: bytes) -> Image.Image | None: + """Открывает изображение для OCR.""" + try: + return Image.open(BytesIO(image_bytes)) + except (OSError, UnidentifiedImageError): + logger.warning("Не удалось открыть изображение для OCR титульного листа (%d байт)", len(image_bytes)) + return None + + +def _image_to_png_bytes(image: Image.Image) -> bytes: + """Сохраняет PIL-изображение в PNG-байты.""" + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _otsu_threshold(image: Image.Image) -> int: + """Считает оптимальный бинарный порог по гистограмме (метод Оцу).""" + histogram = image.histogram()[:256] + total = sum(histogram) + if total == 0: + return OCR_FALLBACK_THRESHOLD + sum_total = sum(intensity * histogram[intensity] for intensity in range(256)) + sum_b = 0.0 + weight_b = 0 + max_variance = -1.0 + threshold = OCR_FALLBACK_THRESHOLD + for intensity in range(256): + weight_b += histogram[intensity] + if weight_b == 0: + continue + weight_f = total - weight_b + if weight_f == 0: + break + sum_b += intensity * histogram[intensity] + mean_b = sum_b / weight_b + mean_f = (sum_total - sum_b) / weight_f + between = weight_b * weight_f * (mean_b - mean_f) ** 2 + if between > max_variance: + max_variance = between + threshold = intensity + return threshold + + +def _scaled_to_target(image: Image.Image) -> Image.Image: + """Приводит изображение к целевому максимальному ребру для OCR.""" + width, height = image.size + longest_edge = max(width, height) + if longest_edge == 0: + return image + scale = OCR_TARGET_MAX_EDGE / longest_edge + if OCR_SCALE_NOOP_LOWER < scale < OCR_SCALE_NOOP_UPPER: + return image + new_size = (max(1, round(width * scale)), max(1, round(height * scale))) + return image.resize(new_size, Image.Resampling.LANCZOS) + + +def _binarize(image: Image.Image, threshold: int) -> Image.Image: + """Бинаризует grayscale-изображение по порогу.""" + return image.point(lambda pixel: 255 if pixel > threshold else 0) + + +def _preprocess_image_variants(image_bytes: bytes) -> list[bytes]: + """Готовит несколько вариантов изображения для OCR.""" + image = _open_image(image_bytes) + if image is None: + return [] + + grayscale = ImageOps.grayscale(image) + contrasted = ImageOps.autocontrast(grayscale, cutoff=1) + base = _scaled_to_target(contrasted) + sharpened = base.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=3)) + denoised = base.filter(ImageFilter.MedianFilter(size=3)) + + otsu_threshold_value = _otsu_threshold(base) + otsu = _binarize(sharpened, otsu_threshold_value) + fallback = _binarize(base, OCR_FALLBACK_THRESHOLD) + denoised_otsu = _binarize(denoised, _otsu_threshold(denoised)) + + variants_with_full_page: list[Image.Image] = [base, sharpened, otsu, fallback, denoised_otsu] + crop_variants: list[Image.Image] = [] + for crop_top_ratio in OCR_BOTTOM_CROPS: + crop_top = int(base.height * crop_top_ratio) + cropped = sharpened.crop((0, crop_top, sharpened.width, sharpened.height)) + crop_variants.append(_binarize(cropped, _otsu_threshold(cropped))) + + variants = [_image_to_png_bytes(image_variant) for image_variant in [*variants_with_full_page, *crop_variants]] + unique_variants: list[bytes] = [] + for variant in variants: + if variant not in unique_variants: + unique_variants.append(variant) + return unique_variants + + +def _run_tesseract(image_bytes: bytes, *, tesseract_path: str, psm: str) -> str | None: + """Запускает Tesseract для одного подготовленного варианта изображения.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_file.write(image_bytes) + temp_path = Path(temp_file.name) + + try: + completed = subprocess.run( # noqa: S603 + [ + tesseract_path, + str(temp_path), + "stdout", + "-l", + "rus+eng", + "--oem", + OCR_TESSERACT_OEM, + "--dpi", + OCR_TESSERACT_DPI, + "--psm", + psm, + ], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return None + finally: + temp_path.unlink(missing_ok=True) + + if completed.returncode != 0: + return None + + text = completed.stdout.strip() + return text or None + + +def _extract_text_candidates_from_title_page(image_bytes: bytes) -> list[str]: + """Запускает OCR в нескольких режимах и возвращает уникальные текстовые кандидаты.""" + prepared_variants = _preprocess_image_variants(image_bytes) + if not prepared_variants: + return [] + + tesseract_path = shutil.which("tesseract") + if tesseract_path is None: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return [] + + candidates: list[str] = [] + for prepared in prepared_variants: + for psm in OCR_PSMS: + text = _run_tesseract(prepared, tesseract_path=tesseract_path, psm=psm) + if text and text not in candidates: + candidates.append(text) + return candidates + + +def extract_text_from_title_page(image_bytes: bytes) -> str | None: + """Запускает системный Tesseract и возвращает OCR-текст страницы.""" + text_candidates = _extract_text_candidates_from_title_page(image_bytes) + if not text_candidates: + return None + + best_parsed = _select_best_parsed_title_page(text_candidates) + if best_parsed is not None: + return best_parsed.raw_text + return text_candidates[0] + + +def parse_title_page_image(image_bytes: bytes) -> ParsedTitlePage | None: + """Выполняет OCR изображения и разбирает распознанный текст.""" + return _select_best_parsed_title_page(_extract_text_candidates_from_title_page(image_bytes)) diff --git a/home_library/providers/yandex.py b/home_library/providers/yandex.py new file mode 100644 index 0000000..895c6b0 --- /dev/null +++ b/home_library/providers/yandex.py @@ -0,0 +1,331 @@ +"""Yandex provider adapter (Playwright + stealth). + +Опциональный провайдер: идёт в ya.ru через headless Chromium с +``playwright-stealth``, что обходит SmartCaptcha для части ISBN, +которые не находятся в основных книжных каталогах. + +Активируется только при ``YANDEX_ENABLED=1`` и установленных +``playwright`` и ``playwright-stealth``. Без флага провайдер тихо +возвращает ``None``, бот работает как раньше. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +from typing import TYPE_CHECKING + +from home_library.domain.models import BookRecord +from home_library.providers._serp_parser import extract_book_from_serp + +if TYPE_CHECKING: + import httpx + +logger = logging.getLogger(__name__) + +YANDEX_ENABLED_ENV = "YANDEX_ENABLED" +YANDEX_SEARCH_URL = "https://ya.ru/search/?text=ISBN%3A+{isbn}" +YANDEX_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" +) +YANDEX_NAV_TIMEOUT_MS = 30_000 +YANDEX_NETWORK_IDLE_TIMEOUT_MS = 15_000 +YANDEX_STEALTH_SETTLE_SECONDS = 2 +YANDEX_H2_LIMIT = 10 +YANDEX_SNIPPET_LIMIT = 10 +YANDEX_LINK_LIMIT = 30 +YANDEX_ENRICH_LINK_LIMIT = 6 +YANDEX_RESULT_PAGE_TIMEOUT_MS = 12_000 + +YANDEX_SERVICE_TITLE_MARKERS = ( + "поиск isbn", + "search for books by isbn", + "search results", + "расширенный поиск", + "связанные запросы", +) + +YANDEX_CAPTCHA_MARKERS = ( + "вы не робот", + "smartcaptcha", + "captcha", +) + +YANDEX_INTERNAL_HOST_MARKERS = ( + "yandex.", + "ya.ru", +) + +YANDEX_TITLE_NOISE_MARKERS = ( + "издательства", + "обложка", + "отзывы", + "рецензии", + "краткое содержание", + "где купить", +) + + +def _looks_like_captcha(title: str, html_text: str) -> bool: + """Определяет, что Yandex показал страницу SmartCaptcha.""" + lowered_title = title.lower() + lowered_html = html_text.lower() + return any(marker in lowered_title or marker in lowered_html for marker in YANDEX_CAPTCHA_MARKERS) + + +def _filter_service_titles(titles: list[str]) -> list[str]: + """Убирает из списка h2-текстов служебные блоки Yandex.""" + cleaned: list[str] = [] + for raw in titles: + lowered = raw.strip().lower() + if not lowered: + continue + if any(marker in lowered for marker in YANDEX_SERVICE_TITLE_MARKERS): + continue + cleaned.append(raw.strip()) + return cleaned + + +def _parse_yandex_serp( + titles: list[str], + snippets: list[str], + isbn: str, +) -> BookRecord | None: + """Разбирает результаты Yandex SERP в ``BookRecord``. + + Тонкая обёртка над общим ``extract_book_from_serp``: просто + выбрасывает служебные блоки Yandex перед парсингом. + """ + return extract_book_from_serp( + _filter_service_titles(titles), + isbn, + snippets=snippets, + ) + + +def _needs_result_page_enrichment(book: BookRecord | None, titles: list[str]) -> bool: + """Определяет, стоит ли добирать метаданные со страниц результатов.""" + if book is None: + return True + if not book.author: + return True + + return any(("..." in raw_title or "…" in raw_title) and book.title in raw_title for raw_title in titles) + + +def _merge_result_page_book(base_book: BookRecord | None, enriched_book: BookRecord | None) -> BookRecord | None: + """Мержит базовый результат SERP с более полными метаданными страницы.""" + if base_book is None: + return enriched_book + if enriched_book is None: + return base_book + + def title_rank(title: str) -> tuple[int, int]: + lowered = title.lower() + penalty = sum(marker in lowered for marker in YANDEX_TITLE_NOISE_MARKERS) + return penalty, -len(title) + + better_title = min((base_book.title, enriched_book.title), key=title_rank) + + return base_book.model_copy( + update={ + "title": better_title, + "author": enriched_book.author or base_book.author, + }, + ) + + +def _is_external_result_link(href: str | None) -> bool: + """Оставляет только внешние ссылки на карточки/страницы книг.""" + if not href or not href.startswith("http"): + return False + lowered = href.lower() + return not any(marker in lowered for marker in YANDEX_INTERNAL_HOST_MARKERS) + + +async def _collect_result_page_candidates(page) -> list[tuple[str, str]]: + """Собирает верхние внешние ссылки из поисковой выдачи Yandex.""" + links = page.locator("a[href]") + count = min(await links.count(), YANDEX_LINK_LIMIT) + candidates: list[tuple[str, str]] = [] + seen_hrefs: set[str] = set() + for index in range(count): + link = links.nth(index) + href = await link.get_attribute("href") + if not _is_external_result_link(href) or href in seen_hrefs: + continue + text = (await link.inner_text()).strip() + if not text: + continue + candidates.append((text, href)) + seen_hrefs.add(href) + if len(candidates) >= YANDEX_ENRICH_LINK_LIMIT: + break + return candidates + + +async def _fetch_book_from_result_pages(context, isbn: str, result_links: list[tuple[str, str]]) -> BookRecord | None: + """Пробует добрать title/author с верхних страниц результатов поиска.""" + from playwright.async_api import Error as PlaywrightError # noqa: PLC0415 + from playwright.async_api import TimeoutError as PlaywrightTimeoutError # noqa: PLC0415 + + best_book: BookRecord | None = None + for link_text, href in result_links: + page = await context.new_page() + try: + await page.goto( + href, + wait_until="domcontentloaded", + timeout=YANDEX_RESULT_PAGE_TIMEOUT_MS, + ) + with contextlib.suppress(PlaywrightTimeoutError): + await page.wait_for_load_state( + "networkidle", + timeout=YANDEX_RESULT_PAGE_TIMEOUT_MS, + ) + + h1_titles = [text.strip() for text in await page.locator("h1").all_text_contents() if text.strip()] + titles = [*h1_titles, link_text] + if not h1_titles: + titles.insert(0, (await page.title()).strip()) + + snippets: list[str] = [] + meta_description = page.locator('meta[name="description"]') + if await meta_description.count(): + content = await meta_description.first.get_attribute("content") + if content: + snippets.append(content.strip()) + + author_texts = await page.locator( + 'a[href*="author"], a[href*="kauthor"], [itemprop="author"]', + ).all_text_contents() + snippets.extend(f"Автор {text.strip()}" for text in author_texts if text.strip()) + + book = extract_book_from_serp(titles, isbn, snippets=snippets) + if book is not None: + best_book = _merge_result_page_book(best_book, book) + except PlaywrightError as exc: + logger.warning( + "Ошибка Playwright при обогащении результата Yandex (ISBN %s, url=%s): %s — %s", + isbn, + href, + type(exc).__name__, + exc, + ) + finally: + await page.close() + + return best_book + + +async def fetch_from_yandex( + isbn: str, + _client: httpx.AsyncClient, +) -> BookRecord | None: + """Ищет книгу по ISBN через Yandex с помощью Playwright+stealth. + + Аргумент ``_client`` не используется — Yandex нельзя брать через + обычный HTTP, нужен headless-браузер. Клиент остаётся в сигнатуре + ради совместимости с другими провайдерами в ``lookup.fetch_book_by_isbn``. + + Guard-условия (любое возвращает ``None`` мгновенно): + - ``YANDEX_ENABLED`` не равен ``"1"``; + - ``playwright`` или ``playwright-stealth`` не установлены. + """ + if os.environ.get(YANDEX_ENABLED_ENV) != "1": + return None + + try: + from playwright.async_api import ( # noqa: PLC0415 + Error as PlaywrightError, + ) + from playwright.async_api import ( # noqa: PLC0415 + TimeoutError as PlaywrightTimeoutError, + ) + from playwright.async_api import ( # noqa: PLC0415 + async_playwright, + ) + from playwright_stealth import Stealth # noqa: PLC0415 + except ImportError as exc: + logger.warning( + "YANDEX_ENABLED=1, но не установлен playwright/playwright-stealth: %s", + exc, + ) + return None + + url = YANDEX_SEARCH_URL.format(isbn=isbn) + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled"], + ) + try: + context = await browser.new_context( + user_agent=YANDEX_USER_AGENT, + locale="ru-RU", + timezone_id="Europe/Moscow", + viewport={"width": 1280, "height": 900}, + ) + await Stealth().apply_stealth_async(context) + page = await context.new_page() + await page.goto( + url, + wait_until="domcontentloaded", + timeout=YANDEX_NAV_TIMEOUT_MS, + ) + with contextlib.suppress(PlaywrightTimeoutError): + await page.wait_for_load_state( + "networkidle", + timeout=YANDEX_NETWORK_IDLE_TIMEOUT_MS, + ) + await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS) + + page_title = await page.title() + page_html = await page.content() + if _looks_like_captcha(page_title, page_html): + logger.warning( + "Yandex вернул SmartCaptcha для ISBN %s (title=%r)", + isbn, + page_title, + ) + return None + + titles = (await page.locator("h2").all_text_contents())[:YANDEX_H2_LIMIT] + snippets = ( + await page.locator( + "div.OrganicTextContentSpan, .organic__text, .VanillaReact", + ).all_text_contents() + )[:YANDEX_SNIPPET_LIMIT] + result_links = await _collect_result_page_candidates(page) + + book = _parse_yandex_serp(titles, snippets, isbn) + if _needs_result_page_enrichment(book, titles): + enriched_book = await _fetch_book_from_result_pages( + context, + isbn, + result_links, + ) + book = _merge_result_page_book(book, enriched_book) + return book + finally: + await browser.close() + except PlaywrightError as exc: + logger.warning( + "Ошибка Playwright при поиске через Yandex (ISBN %s): %s — %s", + isbn, + type(exc).__name__, + exc, + ) + return None + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + "Ошибка парсинга результатов Yandex (ISBN %s): %s — %s", + isbn, + type(exc).__name__, + exc, + ) + return None diff --git a/pyproject.toml b/pyproject.toml index 16f6fdb..3863c35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,3 +57,7 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = ["openpyxl", "openpyxl.*"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["playwright.*", "playwright_stealth"] +ignore_missing_imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt index 4e2247f..53a0168 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,9 @@ mypy==1.20.1 pytest==9.0.3 pytest-asyncio==1.3.0 pytest-cov==7.1.0 + +# Optional: опциональный Yandex-провайдер через headless Chromium + +# playwright-stealth. После установки выполнить `playwright install chromium` +# и включить через `export YANDEX_ENABLED=1`. +playwright>=1.58 +playwright-stealth>=2.0 diff --git a/scripts/yandex_lookup_debug.py b/scripts/yandex_lookup_debug.py new file mode 100644 index 0000000..7cf2e41 --- /dev/null +++ b/scripts/yandex_lookup_debug.py @@ -0,0 +1,101 @@ +"""Отладочный прогон Yandex lookup через Playwright. + +Печатает сырые заголовки выдачи, сниппеты, верхние ссылки и результат +текущего парсера для указанного ISBN. +""" + +from __future__ import annotations + +import asyncio +import os +import pathlib +import sys + +import httpx +from playwright.async_api import TimeoutError as PlaywrightTimeoutError +from playwright.async_api import async_playwright +from playwright_stealth import Stealth + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +from home_library.providers.yandex import ( + YANDEX_NAV_TIMEOUT_MS, + YANDEX_NETWORK_IDLE_TIMEOUT_MS, + YANDEX_SEARCH_URL, + YANDEX_SNIPPET_LIMIT, + YANDEX_STEALTH_SETTLE_SECONDS, + YANDEX_USER_AGENT, + fetch_from_yandex, +) + + +async def main(isbn: str) -> None: + os.environ["YANDEX_ENABLED"] = "1" + + async with httpx.AsyncClient(timeout=10) as client: + parsed = await fetch_from_yandex(isbn, client) + print("PARSED:") + print(parsed.model_dump() if parsed else None) + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled"], + ) + try: + context = await browser.new_context( + user_agent=YANDEX_USER_AGENT, + locale="ru-RU", + timezone_id="Europe/Moscow", + viewport={"width": 1280, "height": 900}, + ) + await Stealth().apply_stealth_async(context) + + page = await context.new_page() + await page.goto( + YANDEX_SEARCH_URL.format(isbn=isbn), + wait_until="domcontentloaded", + timeout=YANDEX_NAV_TIMEOUT_MS, + ) + try: + await page.wait_for_load_state( + "networkidle", + timeout=YANDEX_NETWORK_IDLE_TIMEOUT_MS, + ) + except PlaywrightTimeoutError: + pass + await asyncio.sleep(YANDEX_STEALTH_SETTLE_SECONDS) + + print("\nPAGE TITLE:") + print(await page.title()) + + print("\nH2:") + for item in (await page.locator("h2").all_text_contents())[:15]: + print(repr(item)) + + print("\nSNIPPETS:") + for item in ( + await page.locator( + "div.OrganicTextContentSpan, .organic__text, .VanillaReact", + ).all_text_contents() + )[:YANDEX_SNIPPET_LIMIT]: + print(repr(item)) + + print("\nLINKS:") + links = page.locator("a[href]") + count = min(await links.count(), 40) + for index in range(count): + link = links.nth(index) + text = (await link.inner_text()).strip() + href = await link.get_attribute("href") + if text: + print({"text": text, "href": href}) + finally: + await browser.close() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + msg = "Usage: venv/bin/python scripts/yandex_lookup_debug.py <isbn>" + raise SystemExit(msg) + asyncio.run(main(sys.argv[1])) diff --git a/tests/fixtures/rsl_search_found.json b/tests/fixtures/rsl_search_found.json new file mode 100644 index 0000000..ea132df --- /dev/null +++ b/tests/fixtures/rsl_search_found.json @@ -0,0 +1,8 @@ +{ + "TotalHits": 1, + "TotalHitsExact": true, + "PageNumber": 1, + "PageSize": 10, + "MaxPage": 1, + "content": "\n<div class=\"row\">\n <div class=\"col-xs-12 col-md-11 rsl-descr\">\n <div id=\"spell-container\" class=\"rsl-spell-container\">\n <div id=\"customspell-wr\">\n <i>Возможно, вы имели в виду <b><a href=\"/ru/search#q=\" id=\"spell-link\"></a></b>?</i>\n </div>\n <div id=\"autospell-wr\">\n <i>Запрос <b><span id=\"autospell-replaced\"></span></b> не дал результатов. Произведён поиск по запросу <b><span id=\"autospell-new\"></span></b>.</i>\n </div>\n </div>\n </div>\n</div>\n\n\n<div class=\"rsl-search-info e-filter-page\">\n <b>Результатов: 1, страница 1 / 1</b> (0.173 сек.)\n</div>\n\n\n<div id=\"resultModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"eorderPlanfixModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Электронный заказ документа в читальный зал</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"documentFragmentRequestModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Заказ на оцифровку фрагмента документа</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"eorderModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"newEorderModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"freeAccessAlert\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-sm\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4><b>Этот документ в открытом доступе</b></h4>\n </div>\n <div class=\"modal-body\">\n <p>\n Документ находится в открытом доступе в полном объёме. Вы можете ознакомиться и работать с ним бесплатно в нашем Просмотрщике. </p>\n <p class=\"mt20 text-right\">\n <a id=\"freeAccessAlertReadLink\" class=\"rsl-link\" href=\"#\" target=\"_blank\">Читать онлайн</a>    \n <a id=\"freeAccessAlertEorderLink\" class=\"rsl-record-eorder-btn\" href=\"#\" target=\"_blank\">Заказ копии фрагмента</a> </p>\n </div>\n </div>\n </div>\n</div>\n\n\n<script>\n $(document).ready(function () {\n $('#resultModal, #eorderModal, #documentFragmentRequestModal, #eorderPlanfixModal, #newEorderModal').on('hidden.bs.modal', function () {\n $(this).find('.modal-header h4').text(\"Загрузка...\");\n $(this).find('.modal-body').text(\"Загрузка...\");\n $(this).find('.modal-footer').text('');\n $(this).removeData('bs.modal');\n });\n });\n</script>\n\n<div id=\"content-items\">\n <div id=\"result-page-1\" class=\"result-item\" data-page=\"1\">\n\n\n<div id=\"page-separator-1\" class=\"row page-separator hidden\">\n <div class=\"col-xs-4 col-sm-5\"><hr></div>\n <div class=\"col-xs-4 col-sm-2 text-center page-separator-col\"><span class=\"page-separator-label\">страница 1</span></div>\n <div class=\"col-xs-4 col-sm-5\"><hr></div>\n</div>\n\n\n\n\n<div class=\"row search-container pt10\" data-id=\"01010444981\">\n\n <div class=\"hidden-xs col-sm-1 rsl-position-col\">\n <div class=\"text-center\">\n <h4 class=\"search-item-position\">1</h4>\n </div>\n </div>\n\n <div class=\"col-xs-12 col-sm-11 rsl-search-item-col\">\n <div class=\"search-item p10\">\n <div class=\"rsl-search-itemcover-col\">\n <div class=\"doc-cover book-cover text-center cover-container js-cover-container no-cover\" data-id=\"01010444981\" data-cat=\"rsl01\">\n <div class=\"rsl-item-nocover rsl-item-nocover-title\">Дашкова, Полина</div>\n <div class=\"rsl-item-nocover rsl-item-nocover-descr\">Никто не заплачет : [роман : 16+]</div>\n </div>\n </div>\n\n <div class=\"rsl-search-itemdescr-col\">\n\n <div>\n <b class=\"js-item-authorinfo\" data-id=\"01010444981\">Дашкова, Полина (1960-).</b><br/>\n <span class=\"search-descr\">\n <span class=\"js-item-maininfo\" data-id=\"01010444981\">Никто не заплачет : [роман : 16+] / Полина Дашкова. - Москва : АСТ, cop. 2020. - 411, [2] с.; 22 см. - (Полина Дашкова - лучшая среди лучших).; ISBN <b>978-5-17-122183-6</b> : 2000 экз.</span>\n <span class=\"otherinfo js-expand-block\" style=\"display: none;\">\n \n<div class=\"rsl-item-otherinfo\">\n\t\t\t<div class=\"rsl-item-otherinfo-item\">\n\t\t\t<div class=\"rsl-item-otherinfo-item-name\">\n\t\t\t\tШифр хранения\t\t\t</div>\n\t\t\t<div class=\"rsl-item-otherinfo-item-value\">\n\t\t\t \t\t\t <div>\n\t\t\t <span>FB 10 20-40/219</span>\n\t\t\t \t\t\t </div>\n\t\t\t \t\t\t <div>\n\t\t\t <span>FB 10 20-40/145</span>\n\t\t\t \t\t\t </div>\n\t\t\t \t\t\t</div>\n \t</div>\n\t\t\t\t<div class=\"rsl-item-otherinfo-item\">\n\t\t\t<div class=\"rsl-item-otherinfo-item-name\">\n\t\t\t\tТема\t\t\t</div>\n\t\t\t<div class=\"rsl-item-otherinfo-item-value\">\n \t\t \t\t <span>Филологические науки. Художественная литература -- Российская Федерация -- Русская литература -- с 1991 г. -- Произведения художественной литературы -- Художественная проза -- Романы. Повести. Рассказы -- Приключенческие и детективные романы, повести, рассказы</span>\n\t\t <br />\n\t\t \t\t\t</div>\n\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n </span>\n <a href=\"javascript://\" onclick=\"moreLink($(this));\" class=\"rsl-modal rsl-link-more-separate\">больше</a>\n </span>\n </div>\n\n \n <div class=\"rsl-item-footer-links js-item-subdata-links\">\n \n \n \n <span> </span>\n </div>\n\n \n <div id=\"periodicReleaseList01010444981\"></div>\n\n </div>\n\n <div class=\"clearfix\"></div>\n\n <div class=\"rsl-item-action-links row\">\n <div class=\"col-sm-6 rsl-item-action-read-panel\">\n \n \n <div class=\"rsl-itemaction-link rsl-itemaction-description-link\">\n <a class=\"rsl-modal\" href=\"/ru/record/01010444981\" target=\"_blank\" data-toggle=\"modal\" data-target=\"#resultModal\" data-rid=\"01010444981\">Описание</a> </div>\n </div>\n <div class=\"col-sm-6 rsl-item-action-order-panel\">\n \n <a href=\"#\" class=\"js-favorite-link favorite-link-search\" data-id=\"01010444981\" data-doctype=\"0\" data-permission=\"0\" data-url=\"\">\n <span class=\"rsl-favorite-add\"></span>\n <span class=\"rsl-favorite-text-add\">В избранное</span>\n <span class=\"rsl-favorite-text-in\" style=\"display: none;\">В избранном</span>\n </a>\n\n <div class=\"rsl-eorder-dropdown-menu js-eorder-dropdown-menu\" data-id=\"01010444981\">\n <a class=\"rsl-eorder-dropdown-btn\" href=\"#\">Заказать</a> <div class=\"rsl-eorder-dropdown-content\">\n <a class=\"js-eorder-request-link rsl-modal\" href=\"/ru/eorder/request?id=01010444981\" data-toggle=\"modal\" data-target=\"#newEorderModal\" data-rid=\"01010444981\">В читальный зал</a> <a class=\"rsl-payment-copy-link js-payment-copy-link\" href=\"/ru/fragment-eorder/rsl01010444981\" target=\"_blank\" data-id=\"01010444981\">Копию фрагмента<br/><span class=\"rsl-payment-copy-long-time-text\">1-5 рабочих дней</span><span class=\"rsl-payment-copy-short-time-text\">2 минуты</span></a> </div>\n </div>\n </div>\n </div>\n\n <div class=\"clearfix\"></div>\n\n \n </div>\n </div>\n\n</div>\n\n\n\n</div>\n</div>\n\n" +} \ No newline at end of file diff --git a/tests/fixtures/rsl_search_notfound.json b/tests/fixtures/rsl_search_notfound.json new file mode 100644 index 0000000..8ec3f80 --- /dev/null +++ b/tests/fixtures/rsl_search_notfound.json @@ -0,0 +1,8 @@ +{ + "TotalHits": 0, + "TotalHitsExact": true, + "PageNumber": 1, + "PageSize": 10, + "MaxPage": 1, + "content": "\n<div class=\"row\">\n <div class=\"col-xs-12 col-md-11 rsl-descr\">\n <div id=\"spell-container\" class=\"rsl-spell-container\">\n <div id=\"customspell-wr\">\n <i>Возможно, вы имели в виду <b><a href=\"/ru/search#q=\" id=\"spell-link\"></a></b>?</i>\n </div>\n <div id=\"autospell-wr\">\n <i>Запрос <b><span id=\"autospell-replaced\"></span></b> не дал результатов. Произведён поиск по запросу <b><span id=\"autospell-new\"></span></b>.</i>\n </div>\n </div>\n </div>\n</div>\n\n\n<div class=\"rsl-search-info e-filter-page\">\n <b>Результатов: 0, страница 1 / 1</b> (0.162 сек.)\n</div>\n\n\n<div id=\"resultModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"eorderPlanfixModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Электронный заказ документа в читальный зал</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"documentFragmentRequestModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Заказ на оцифровку фрагмента документа</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"eorderModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"newEorderModal\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4>Загрузка...</h4>\n </div>\n <div class=\"modal-body\">Загрузка...</div>\n <div class=\"modal-footer\"></div>\n </div>\n </div>\n</div>\n\n<div id=\"freeAccessAlert\" class=\"fade modal\" role=\"dialog\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-sm\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-hidden=\"true\">×</button>\n <h4><b>Этот документ в открытом доступе</b></h4>\n </div>\n <div class=\"modal-body\">\n <p>\n Документ находится в открытом доступе в полном объёме. Вы можете ознакомиться и работать с ним бесплатно в нашем Просмотрщике. </p>\n <p class=\"mt20 text-right\">\n <a id=\"freeAccessAlertReadLink\" class=\"rsl-link\" href=\"#\" target=\"_blank\">Читать онлайн</a>    \n <a id=\"freeAccessAlertEorderLink\" class=\"rsl-record-eorder-btn\" href=\"#\" target=\"_blank\">Заказ копии фрагмента</a> </p>\n </div>\n </div>\n </div>\n</div>\n\n\n<script>\n $(document).ready(function () {\n $('#resultModal, #eorderModal, #documentFragmentRequestModal, #eorderPlanfixModal, #newEorderModal').on('hidden.bs.modal', function () {\n $(this).find('.modal-header h4').text(\"Загрузка...\");\n $(this).find('.modal-body').text(\"Загрузка...\");\n $(this).find('.modal-footer').text('');\n $(this).removeData('bs.modal');\n });\n });\n</script>\n\n<div id=\"content-items\">\n <div id=\"result-page-1\" class=\"result-item\" data-page=\"1\">\n\n<div class=\"row pt10\">\n <div class=\"col-xs-12 col-md-11 rsl-descr\">\n <i>\n По вашему запросу ничего не найдено. Вы можете изменить поиск или провести его по <a href=\"https://search.rsl.ru/images\">имидж-каталогам</a>. </i>\n </div>\n</div>\n\n</div>\n</div>\n\n" +} \ No newline at end of file diff --git a/tests/fixtures/title_page_chelomova.jpg b/tests/fixtures/title_page_chelomova.jpg new file mode 100644 index 0000000..a47f905 Binary files /dev/null and b/tests/fixtures/title_page_chelomova.jpg differ diff --git a/tests/helpers/bot_driver.py b/tests/helpers/bot_driver.py index b996cb3..5377ef2 100644 --- a/tests/helpers/bot_driver.py +++ b/tests/helpers/bot_driver.py @@ -141,6 +141,8 @@ async def click(self, callback_data: str) -> FakeCallbackQuery: if callback_data.startswith("add_book_"): await handlers.handle_add_book_callback(update, self.context) + elif callback_data.startswith("scan:"): + await handlers.handle_scan_draft_callback(update, self.context) elif callback_data.startswith("edit:"): await handlers.handle_edit_callback(update, self.context) elif callback_data.startswith("set:"): diff --git a/tests/test_home_library_e2e.py b/tests/test_home_library_e2e.py index fa4e369..9732675 100644 --- a/tests/test_home_library_e2e.py +++ b/tests/test_home_library_e2e.py @@ -5,12 +5,12 @@ from home_library import config from home_library.domain.models import BookRecord, EditState from home_library.interfaces.telegram import handlers +from home_library.providers.title_page import ParsedTitlePage from home_library.storage import sqlite as db from home_library.storage import users_sqlite as access_db from tests.conftest import make_book from tests.helpers.bot_driver import BotDriver -PHOTO_FLOW_REPLY_COUNT = 2 OWNER_TELEGRAM_USER_ID = 900 ANNA_TELEGRAM_USER_ID = 901 ANNA_NEW_TELEGRAM_USER_ID = 902 @@ -70,18 +70,61 @@ async def test_text_search_reports_missing_author_in_demo_db(test_db) -> None: @pytest.mark.asyncio async def test_handle_photo_reports_unreadable_barcode(test_db, monkeypatch) -> None: - """Проверяет отрицательную ветку распознавания штрихкода.""" + """Даже без штрихкода бот показывает черновик для ручной проверки.""" monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: None) driver = BotDriver() await driver.send_photo() - assert driver.last_reply_text().startswith("Не удалось распознать штрихкод") + assert "Что удалось распознать:" in driver.last_reply_text() + assert "ISBN: не найден" in driver.last_reply_text() + assert driver.user_data.get("pending_scan_draft") is not None + + +@pytest.mark.asyncio +async def test_scan_draft_keyboard_separates_confirm_lookup_and_cancel_buttons(test_db, monkeypatch) -> None: + """Кнопки подтверждения, поиска и отмены должны быть в отдельных строках.""" + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + driver = BotDriver() + + await driver.send_photo() + + reply_markup = driver.reply()["kwargs"]["reply_markup"] + assert reply_markup.inline_keyboard[3][0].text == "✅ Всё верно, добавить в библиотеку" + assert reply_markup.inline_keyboard[4][0].text == "🔎 Искать информацию" + assert reply_markup.inline_keyboard[5][0].text == "❌ Отмена" + + +@pytest.mark.asyncio +async def test_scan_draft_confirm_adds_book_without_lookup(test_db, monkeypatch) -> None: + """Подтверждение scan draft сразу добавляет книгу и не запускает внешний поиск.""" + calls = 0 + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + nonlocal calls + calls += 1 + return make_book(title="Чистая архитектура", author="Роберт Мартин", isbn="9785446110773") + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + + callback = await driver.click(confirm_callback) + + assert callback.answered is True + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "📦 ISBN: 9785446110773" in callback.edits[0]["text"] + assert calls == 0 + assert driver.user_data.get("pending_book") is None + assert db.find_existing_book("", "", isbn="9785446110773") is not None @pytest.mark.asyncio async def test_handle_photo_finds_existing_book_by_barcode_in_db(test_db, monkeypatch) -> None: - """Проверяет ветку ISBN, который уже есть в каталоге.""" + """Кнопка поиска из черновика находит существующую книгу по ISBN.""" async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: return make_book( @@ -96,17 +139,21 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert "📦 Найден ISBN" in driver.reply(0)["text"] - assert "Книга уже есть в каталоге" in driver.reply(1)["text"] - assert "Взрослые дети эмоционально незрелых родителей" in driver.reply(1)["text"] + callback = await driver.click(lookup_callback) + + assert callback.answered is True + assert callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "Книга уже есть в каталоге" in driver.last_reply_text() + assert "Взрослые дети эмоционально незрелых родителей" in driver.last_reply_text() assert "pending_book" not in driver.user_data @pytest.mark.asyncio async def test_handle_photo_adds_new_book_by_barcode_after_confirmation(test_db, monkeypatch) -> None: - """Проверяет полный flow добавления новой книги по ISBN.""" + """Проверяет полный flow: черновик -> поиск по ISBN -> preview -> добавление.""" async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: return make_book( @@ -123,14 +170,17 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() + scan_reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = scan_reply_markup.inline_keyboard[4][0].callback_data + + await driver.click(lookup_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert "Найдена книга" in driver.reply(1)["text"] + assert "Найдена книга" in driver.last_reply_text() pending_book = driver.user_data.get("pending_book") assert isinstance(pending_book, BookRecord) assert pending_book.title == "Чистая архитектура" - reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - confirm_callback = reply_markup.inline_keyboard[0][0].callback_data + reply_markup = driver.reply()["kwargs"]["reply_markup"] + confirm_callback = reply_markup.inline_keyboard[3][0].callback_data callback = await driver.click(confirm_callback) @@ -141,8 +191,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: @pytest.mark.asyncio -async def test_handle_photo_rejects_stale_add_book_confirmation(test_db, monkeypatch) -> None: - """Старая кнопка подтверждения не должна добавлять новую pending-книгу.""" +async def test_handle_photo_rejects_stale_scan_draft_confirmation(test_db, monkeypatch) -> None: + """Старая кнопка поиска из черновика не должна запускать поиск по новым данным.""" books_by_isbn = { "9785446110773": make_book( title="Чистая архитектура", @@ -169,30 +219,31 @@ async def fake_fetch_book_by_isbn(isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() - first_reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - stale_confirm_callback = first_reply_markup.inline_keyboard[0][0].callback_data + first_reply_markup = driver.reply()["kwargs"]["reply_markup"] + stale_lookup_callback = first_reply_markup.inline_keyboard[4][0].callback_data await driver.send_photo() - second_reply_markup = driver.reply(1)["kwargs"]["reply_markup"] - current_confirm_callback = second_reply_markup.inline_keyboard[0][0].callback_data + second_reply_markup = driver.reply()["kwargs"]["reply_markup"] + current_lookup_callback = second_reply_markup.inline_keyboard[4][0].callback_data - stale_callback = await driver.click(stale_confirm_callback) + stale_callback = await driver.click(stale_lookup_callback) assert stale_callback.answered is True - assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново." + assert stale_callback.edits[0]["text"] == "Данные распознавания устарели. Отправь фото заново." assert db.find_existing_book("Чистая архитектура", "Роберт Мартин", isbn="9785446110773") is None assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None - current_callback = await driver.click(current_confirm_callback) + current_callback = await driver.click(current_lookup_callback) assert current_callback.answered is True - assert "✅ Книга добавлена в каталог" in current_callback.edits[0]["text"] - assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is not None + assert current_callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "Найдена книга" in driver.last_reply_text() + assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None @pytest.mark.asyncio async def test_handle_photo_reports_missing_book_outside_db(test_db, monkeypatch) -> None: - """Проверяет отрицательную ветку ISBN вне каталога и вне провайдеров.""" + """По кнопке поиска из черновика бот уходит в fallback с ISBN hint.""" async def fake_fetch_book_by_isbn(_isbn: str) -> None: return None @@ -202,12 +253,429 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data + + await driver.click(lookup_callback) + + assert driver.last_reply_text() == ( + "Книга с ISBN 9789999999999 не найдена.\n" + "Напиши название, автора или часть — попробую с подсказкой (или /cancel)." + ) + pending_hint = driver.user_data.get("pending_isbn_hint") + assert pending_hint is not None + assert pending_hint.isbn == "9789999999999" + + +@pytest.mark.asyncio +async def test_isbn_hint_flow_resolves_book_via_ddg(test_db, monkeypatch) -> None: + """Пользователь подсказал название — книга находится через DDG с контекстом.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> None: + return None + + async def fake_fetch_from_ddg_with_context(isbn, hint, _client): + assert isbn == "9785961449136" + assert hint == "Мужские правила" + return BookRecord( + title="Мужские правила: Отношения, секс, психология", + author="Марк Мэнсон", + publisher="", + topics="", + link="", + isbn=isbn, + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785961449136") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + monkeypatch.setattr( + handlers.callbacks, + "fetch_from_ddg_with_context", + fake_fetch_from_ddg_with_context, + ) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + assert driver.user_data.get("pending_isbn_hint") is not None + + await driver.send_text("Мужские правила") + + assert "Мужские правила: Отношения, секс, психология" in driver.last_reply_text() + assert "Добавить в каталог?" in driver.last_reply_text() + assert driver.user_data.get("pending_isbn_hint") is None + assert driver.user_data.get("pending_book") is not None + + +@pytest.mark.asyncio +async def test_isbn_hint_flow_reports_missing_book_when_ddg_empty( + test_db, + monkeypatch, +) -> None: + """Если DDG с подсказкой тоже ничего не дал — бот сообщает и очищает state.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> None: + return None + + async def fake_fetch_from_ddg_with_context(_isbn, _hint, _client): + return None + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9789999999999") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + monkeypatch.setattr( + handlers.callbacks, + "fetch_from_ddg_with_context", + fake_fetch_from_ddg_with_context, + ) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + await driver.send_text("Подсказка") + + assert driver.last_reply_text() == ( + "По подсказке тоже ничего не нашлось. Попробуй другой ISBN или добавь книгу вручную." + ) + assert driver.user_data.get("pending_isbn_hint") is None + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_isbn_hint(test_db, monkeypatch) -> None: + """/cancel сбрасывает ожидание подсказки.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> None: + return None + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9789999999999") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + assert driver.user_data.get("pending_isbn_hint") is not None + + await driver.cancel() + + assert driver.user_data.get("pending_isbn_hint") is None + assert driver.last_reply_text() == "Ожидание подсказки по ISBN отменено." + + +@pytest.mark.asyncio +async def test_scan_draft_accepts_title_page_verso_photo_and_manual_edit(test_db, monkeypatch) -> None: + """Второе фото оборота титульного листа дополняет черновик и допускает ручную правку.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785446118168", + author="Сью Алекс", + title="System Design. Подготовка к сложному интервью", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert "Автор: Сью Алекс" in driver.last_reply_text() + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Сью Алекс" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.send_text("Алекс Сью") + + assert "Автор: Алекс Сью" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].text == "📖 Прислать другое фото оборота" + + +@pytest.mark.asyncio +async def test_scan_draft_accepts_manual_edit_for_isbn_and_title_after_verso(test_db, monkeypatch) -> None: + """После OCR с оборота можно вручную исправить ISBN и название без потери verso-режима.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + edit_isbn_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785042195730" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-04-219573-1") + + assert "ISBN: 9785042195731" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + + edit_title_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "Книготерапия : научно доказанный метод самопомощи" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("Книготерапия") - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert driver.reply(1)["text"] == ( - "Книга с ISBN 9789999999999 не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск." + assert "Название: Книготерапия" in driver.last_reply_text() + assert "Всё верно?" in driver.last_reply_text() + + +@pytest.mark.asyncio +async def test_scan_draft_confirm_after_verso_edit_adds_book_directly(test_db, monkeypatch) -> None: + """После OCR с оборота и ручной правки автора подтверждение сразу добавляет книгу.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), ) - assert driver.user_data == {} + await driver.send_photo(payload=b"verso") + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + await driver.send_text("Челомова Надежда Алексеевна") + + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + callback = await driver.click(confirm_callback) + + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "Челомова Надежда Алексеевна" in callback.edits[0]["text"] + assert "Добавить в каталог?" not in callback.edits[0]["text"] + assert driver.user_data.get("pending_book") is None + + +@pytest.mark.asyncio +async def test_scan_draft_blocks_continue_while_field_edit_is_active(test_db, monkeypatch) -> None: + """Во время ручной правки scan draft кнопка поиска не должна срабатывать.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785042195730") + driver = BotDriver() + + await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data + edit_author_callback = reply_markup.inline_keyboard[0][1].callback_data + + await driver.click(edit_author_callback) + callback = await driver.click(lookup_callback) + + assert callback.answer_kwargs == { + "text": "Сначала заверши редактирование поля.", + "show_alert": True, + } + assert callback.edits == [] + + +@pytest.mark.asyncio +async def test_pending_book_preview_supports_editing_before_add(test_db, monkeypatch) -> None: + """Перед добавлением книги можно исправить ISBN, автора и название.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Сью Алекс" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.send_text("Алекс Сью") + assert "✍️ Алекс Сью" in driver.last_reply_text() + updated_preview_markup = driver.reply()["kwargs"]["reply_markup"] + + edit_isbn_callback = updated_preview_markup.inline_keyboard[0][0].callback_data + stale_confirm_callback = preview_markup.inline_keyboard[3][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785446118168" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-4461-1816-0") + assert "📦 ISBN: 9785446118160" in driver.last_reply_text() + + updated_preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_title_callback = updated_preview_markup.inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "System Design. Подготовка к сложному интервью" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("System Design") + assert "📖 <b>System Design</b>" 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 "📖 <b>System Design. Подготовка к сложному интервью</b>" in driver.last_reply_text() + assert "✍️ Алекс Сью" in driver.last_reply_text() + assert "🏢 Питер" in driver.last_reply_text() + pending_book = driver.user_data.get("pending_book") + assert isinstance(pending_book, BookRecord) + assert pending_book.author == "Алекс Сью" + assert pending_book.publisher == "Питер" + + +@pytest.mark.asyncio +async def test_pending_book_preview_blocks_add_while_field_edit_is_active(test_db, monkeypatch) -> None: + """Во время ручной правки preview книги кнопка добавления не должна срабатывать.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + confirm_callback = preview_markup.inline_keyboard[3][0].callback_data + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + + await driver.click(edit_author_callback) + callback = await driver.click(confirm_callback) + + assert callback.answer_kwargs == { + "text": "Сначала заверши редактирование поля.", + "show_alert": True, + } + assert callback.edits == [] + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_preview_book_edit(test_db, monkeypatch) -> None: + """/cancel сбрасывает режим ручной правки книги перед добавлением.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="Чистая архитектура", + author="Роберт Мартин", + publisher="Питер", + isbn="9785446110773", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446110773") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_callback) + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Роберт Мартин" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.cancel() + + assert driver.user_data.get("pending_book_editing") is None + assert driver.last_reply_text() == "Редактирование книги перед добавлением отменено." + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_scan_draft(test_db, monkeypatch) -> None: + """/cancel сбрасывает ожидание проверки распознанных данных.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + assert driver.user_data.get("pending_scan_draft") is not None + + await driver.cancel() + + assert driver.user_data.get("pending_scan_draft") is None + assert driver.last_reply_text() == "Проверка распознанных данных отменена." @pytest.mark.asyncio diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 7117348..8db4920 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -1,10 +1,35 @@ """Tests for provider helpers.""" +import builtins as _builtins +import io +import json +import shutil +from pathlib import Path + import httpx import pytest +from PIL import Image from home_library.domain.models import BookRecord -from home_library.providers import barcode, ddg, google_books, labirint, lookup, piter +from home_library.providers import ( + _serp_parser, + barcode, + ddg, + google_books, + labirint, + lookup, + piter, + playwright_site_search, + rsl_rkp, + title_page, + yandex, +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +OTSU_THRESHOLD_MIN = 50 +OTSU_THRESHOLD_MAX = 200 +PREPROCESS_MIN_VARIANTS = 5 # --------------------------------------------------------------------------- # Barcode @@ -19,6 +44,175 @@ def test_empty_bytes_returns_none(self): assert barcode.decode_barcode(b"") is None +class TestTitlePageParsing: + def test_extract_isbn_from_text(self): + text = "ISBN 978-5-4461-1816-8\nББК 32.973.2-02" + + assert title_page.extract_isbn_from_text(text) == "9785446118168" + + def test_parse_title_page_text_extracts_author_title_and_isbn(self): + text = ( + "C98\n" + "Сью Алекс\n" + "System Design. Подготовка к сложному интервью. — СПб.: Питер, 2022.\n" + "304 с.: ил.\n" + "ISBN 978-5-4461-1816-8\n" + "ББК 32.973.2-02\n" + "УДК 004.41\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785446118168" + assert parsed.author == "Сью Алекс" + assert parsed.title == "System Design. Подготовка к сложному интервью" + + def test_parse_title_page_text_returns_none_for_noise(self): + text = "ББК 32.973.2-02\nУДК 004.41\n© Byte Code LLC" + + assert title_page.parse_title_page_text(text) is None + + def test_parse_title_page_text_strips_control_code_from_title(self): + text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + "Чтение может исцелять, вдохновлять и менять жизнь.\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_parse_title_page_text_prefers_bibliographic_block_near_isbn(self): + text = ( + "УДК 159.9\n" + "ББК 88.52\n" + "П21\n" + "Marianne Power\n" + "HELP ME!\n" + "One woman's quest to find out if self-help\n" + "really can change her life\n" + "Copyright © Marianne Power 2018\n" + "Пауэр, Мэриэнн.\n" + "П21 Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново / Мэриэнн Пауэр; [перевод с английского Я.О. Мышкиной]. - Москва : Эксмо, 2022. - 448 с.\n" + "ISBN 978-5-04-161694-6\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785041616946" + assert parsed.author == "Пауэр, Мэриэнн" + assert ( + parsed.title + == "Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново" + ) + + def test_parse_title_page_text_discards_low_quality_author_and_title(self): + text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "" + assert parsed.title == "" + + def test_parse_title_page_image_prefers_best_ocr_candidate(self, monkeypatch): + noisy_text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + clean_text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + ) + + monkeypatch.setattr( + title_page, + "_extract_text_candidates_from_title_page", + lambda _image_bytes: [noisy_text, clean_text], + ) + + parsed = title_page.parse_title_page_image(b"image-bytes") + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_strip_ocr_tail_noise_drops_short_garbage_after_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. и: ОЕ") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_garbage_between_two_terminators(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. Е.") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_trailing_single_letter_without_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Книготерапия / Надежда Чело- В") + + assert cleaned == "Книготерапия / Надежда Чело-" + + def test_strip_ocr_lead_noise_drops_single_letter_prefix(self): + cleaned = title_page._strip_ocr_lead_noise("j Ч-39 Книготерапия : научно доказанный") + + assert cleaned == "Ч-39 Книготерапия : научно доказанный" + + def test_join_hyphenated_lines_merges_word_split(self): + joined = title_page._join_hyphenated_lines( + ["Надежда Чело-", "мова. — Москва : Эксмо, 2025"], + ) + + assert joined == ["Надежда Челомова. — Москва : Эксмо, 2025"] + + def test_otsu_threshold_returns_value_between_min_and_max(self): + gradient = Image.new("L", (256, 1)) + for x in range(256): + gradient.putpixel((x, 0), x) + + threshold = title_page._otsu_threshold(gradient) + + assert OTSU_THRESHOLD_MIN < threshold < OTSU_THRESHOLD_MAX + + def test_preprocess_image_variants_includes_multiple_unique_variants(self): + image = Image.new("L", (200, 280), color=255) + for x in range(200): + for y in range(140, 280): + image.putpixel((x, y), 0) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + + variants = title_page._preprocess_image_variants(buffer.getvalue()) + + assert len(variants) >= PREPROCESS_MIN_VARIANTS + + @pytest.mark.skipif( + shutil.which("tesseract") is None, + reason="tesseract is not installed in this environment", + ) + def test_parse_title_page_image_handles_chelomova_photo(self): + image_path = FIXTURES_DIR / "title_page_chelomova.jpg" + + parsed = title_page.parse_title_page_image(image_path.read_bytes()) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert "Челомова" in parsed.author + assert "Книготерапия" in parsed.title + + # --------------------------------------------------------------------------- # Google Books # --------------------------------------------------------------------------- @@ -231,28 +425,61 @@ def test_no_title_tag_returns_none(self): # --------------------------------------------------------------------------- -class TestCleanDdgTitle: +class TestCleanTitle: def test_strips_shop_suffix(self): - assert ddg._clean_ddg_title("Война и мир - Ozon") == "Война и мир" + assert _serp_parser._clean_title("Война и мир - Ozon") == "Война и мир" def test_strips_domain_prefix(self): - assert ddg._clean_ddg_title("ozon.ru: Война и мир") == "Война и мир" + assert _serp_parser._clean_title("ozon.ru: Война и мир") == "Война и мир" def test_strips_trailing_ellipsis(self): - assert ddg._clean_ddg_title("Война и мир...") == "Война и мир" + assert _serp_parser._clean_title("Война и мир...") == "Война и мир" def test_strips_multiple_separators(self): - assert ddg._clean_ddg_title("Война и мир | Литрес - Ozon") == "Война и мир" + assert _serp_parser._clean_title("Война и мир | Литрес - Ozon") == "Война и мир" + + def test_strips_alpina_series_article_and_age_marks(self): + assert ( + _serp_parser._clean_title( + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + ) + == "От 0 до 5.Простые подсказки для умных родителей" + ) + + def test_strips_article_suffix_without_alpina_prefix(self): + assert _serp_parser._clean_title("Просто книга арт. 9785916717099") == "Просто книга" + + def test_strips_marketplace_prefix_and_edition_suffix(self): + assert ( + _serp_parser._clean_title( + "Нехудожественная книга Альпина От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.", + ) + == "От 0 до 5: Простые подсказки для умных родителей" + ) + + def test_strips_kupit_v_suffix_and_publisher(self): + assert ( + _serp_parser._clean_title("Мужские правила Марк Мэнсон - купить в Альпина.") + == "Мужские правила Марк Мэнсон" + ) + + def test_strips_publisher_suffix_alpina(self): + assert ( + _serp_parser._clean_title( + "Мужские правила: Отношения, секс, психология Альпина...", + ) + == "Мужские правила: Отношения, секс, психология" + ) -class TestExtractDdgBook: +class TestExtractBookFromSerp: def test_with_author_via_emdash(self): titles = [ "Толстой — Война и мир", "Война и мир | Лабиринт", "Война и мир купить", ] - result = ddg._extract_ddg_book(titles, "9785171234567") + result = _serp_parser.extract_book_from_serp(titles, "9785171234567") assert result is not None assert result.title == "Война и мир" assert result.author == "Толстой" @@ -262,21 +489,108 @@ def test_without_author(self): "Война и мир | Лабиринт", "Война и мир - Ozon", ] - result = ddg._extract_ddg_book(titles, "9785171234567") + result = _serp_parser.extract_book_from_serp(titles, "9785171234567") assert result is not None assert result.title == "Война и мир" assert result.author == "" + def test_extracts_author_from_snippet(self): + titles = [ + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...", + ] + snippets = [ + "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", + ] + + result = _serp_parser.extract_book_from_serp( + titles, + "9785916717099", + snippets=snippets, + ) + + assert result is not None + assert result.title == "От 0 до 5.Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + + def test_prefers_cleaner_title_when_frequency_is_equal(self): + titles = [ + "Чистое название", + "Чистое название / служебный хвост изд ...", + ] + + result = _serp_parser.extract_book_from_serp(titles, "9785171234567") + + assert result is not None + assert result.title == "Чистое название" + + def test_extracts_specific_alpina_non_fiction_book(self): + titles = [ + "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley ...", + "От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.", + "Нехудожественная книга Альпина От 0 до 5. Простые подсказки для умных ...", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...", + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + ] + snippets = [ + "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley: 9785916717099: Trejsi Katchlou: Books", + "Купить Учебники.Словари.Энциклопедии От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т. Катчлоу Трейси 9785916717099", + "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд-во, Автор 0, книга, isbn:9785916717099,", + ] + + result = _serp_parser.extract_book_from_serp( + titles, + "9785916717099", + snippets=snippets, + ) + + assert result is not None + assert result.title == "От 0 до 5: Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + + def test_keeps_author_order_when_snippet_already_has_name_then_surname(self): + titles = [ + "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук", + ] + snippets = [ + "Автор Тамара Макклинток-Гринберг", + ] + + result = _serp_parser.extract_book_from_serp( + titles, + "9785002143382", + snippets=snippets, + ) + + assert result is not None + assert result.author == "Тамара Макклинток-Гринберг" + + def test_extracts_author_and_prefers_full_title_for_9785002143382(self): + titles = [ + "Макклинток-Гринберг Тамара: Комплексное птср.", + "Книга Комплексное ПТСР. Как справиться с гневом...", + "Комплексное ПТСР. Как справиться с гневом и страхом...", + "МИФ. Комплексное ПТСР, мягкая обложка...", + "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук", + ] + + result = _serp_parser.extract_book_from_serp(titles, "9785002143382") + + assert result is not None + assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук" + assert result.author == "Тамара Макклинток-Гринберг" + def test_non_cyrillic_filtered_returns_none(self): titles = [ "War and Peace - Amazon", "War and Peace | eBay", ] - result = ddg._extract_ddg_book(titles, "9785171234567") + result = _serp_parser.extract_book_from_serp(titles, "9785171234567") assert result is None def test_empty_titles_returns_none(self): - result = ddg._extract_ddg_book([], "9785171234567") + result = _serp_parser.extract_book_from_serp([], "9785171234567") assert result is None @@ -301,6 +615,26 @@ async def test_success(self): assert result is not None assert result.title == "Война и мир" + @pytest.mark.asyncio + async def test_success_with_author_from_snippet(self): + html = """ + <html> + <body> + <a class="result__a" href="https://example.com">АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099</a> + <a class="result__snippet" href="https://example.com">Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина</a> + </body> + </html> + """ + transport = httpx.MockTransport( + lambda req: httpx.Response(200, text=html), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await ddg.fetch_from_ddg("9785916717099", client) + + assert result is not None + assert result.title == "От 0 до 5.Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + @pytest.mark.asyncio async def test_http_error_returns_none(self): transport = httpx.MockTransport( @@ -330,6 +664,325 @@ def raise_error(_request: httpx.Request) -> httpx.Response: assert result is None +class TestFetchFromDdgWithContext: + @pytest.mark.asyncio + async def test_query_uses_isbn_plus_hint(self): + seen_queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_queries.append(request.url.params.get("q", "")) + return httpx.Response(200, text=DDG_SEARCH_HTML) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + await ddg.fetch_from_ddg_with_context( + "9785171234567", + "Мужские правила", + client, + ) + assert seen_queries == ["9785171234567 Мужские правила"] + + @pytest.mark.asyncio + async def test_empty_hint_falls_back_to_isbn_only(self): + seen_queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_queries.append(request.url.params.get("q", "")) + return httpx.Response(200, text=DDG_SEARCH_HTML) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + await ddg.fetch_from_ddg_with_context("9785171234567", " ", client) + assert seen_queries == ["9785171234567"] + + @pytest.mark.asyncio + async def test_alpina_9785961449136_regression(self): + html = """ + <html> + <body> + <a class="result__a" href="https://litres.ru/one">Марк Мэнсон — Мужские правила: Отношения, секс, психология</a> + <a class="result__snippet" href="https://litres.ru/one">Марк Мэнсон. Мужские правила: Отношения, секс, психология. ISBN 9785961449136.</a> + <a class="result__a" href="https://ozon.ru/two">ozon.ru: Мужские правила: Отношения, секс, психология...</a> + <a class="result__snippet" href="https://ozon.ru/two">Автор Мэнсон Марк Издательство Альпина Паблишер ISBN 9785961449136</a> + <a class="result__a" href="https://chitai-gorod.ru/three">Мужские правила: Отношения, секс, психология - Читай-город</a> + <a class="result__a" href="https://alpina.ru/four">Мужские правила: Отношения, секс, психология — Альпина Паблишер</a> + <a class="result__a" href="https://book24.ru/five">Мужские правила: Отношения, секс, психология | Book24</a> + </body> + </html> + """ + 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 = """ + <a href="/book/1008960919-kompleksnoe-ptsr" title="Тамара Макклинток-Гринберг - Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук"> + Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук</a> + <a href="/author/2447293-tamara-makklintokgrinberg" title="Тамара Макклинток-Гринберг">Тамара Макклинток-Гринберг</a> + """ + + 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 = """ + <a href="/author/tamara-makklintok-grinberg/kompleksnoe-ptsr-kak-spravitsya-s-gnevom-i-strahom/"> + <p>Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук</p> + </a> + <a href="/author/tamara-makklintok-grinberg/"> + <div>Тамара Макклинток-Гринберг</div> + </a> + """ + + 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 = '<a class="product-title-link" href="/books/980596/">Комплексное ПТСР</a>' + + 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 = """ + <a class="product-title-link" href="/books/980596/">Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук</a> + <a href="/authors/294181/">Макклинток-Гринберг Тамара</a> + <a href="/pubhouse/833/">Манн, Иванов и Фербер</a> + """ + + result = playwright_site_search._parse_labirint_search_page( + html, + "9785002143382", + ) + + assert result is not None + assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук" + assert result.author == "Макклинток-Гринберг Тамара" + assert result.publisher == "Манн, Иванов и Фербер" + assert result.link == "https://www.labirint.ru/books/980596/" + + +class TestFetchFromPlaywrightSiteSearch: + @pytest.mark.asyncio + async def test_disabled_without_flag(self, monkeypatch): + monkeypatch.delenv("PLAYWRIGHT_SITE_SEARCH_ENABLED", raising=False) + async with httpx.AsyncClient() as client: + result = await playwright_site_search.fetch_from_playwright_site_search( + "9785002143382", + client, + ) + assert result is None + + @pytest.mark.asyncio + async def test_missing_playwright_returns_none(self, monkeypatch): + monkeypatch.setenv("PLAYWRIGHT_SITE_SEARCH_ENABLED", "1") + real_import = _builtins.__import__ + + def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0): + if name.startswith(("playwright", "playwright_stealth")): + raise ImportError(f"mocked missing {name}") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(_builtins, "__import__", fake_import) + async with httpx.AsyncClient() as client: + result = await playwright_site_search.fetch_from_playwright_site_search( + "9785002143382", + client, + ) + assert result is None + + +class TestYandexEnrichmentHelpers: + def test_needs_result_page_enrichment_for_truncated_title(self): + book = BookRecord( + title="Комплексное ПТСР. Как справиться с гневом и страхом", + author="Тамара Макклинток-Гринберг", + isbn="9785002143382", + ) + titles = [ + "Комплексное ПТСР. Как справиться с гневом и страхом...", + "Макклинток-Гринберг Тамара: Комплексное птср.", + ] + + assert yandex._needs_result_page_enrichment(book, titles) is True + + def test_merge_result_page_book_prefers_longer_title_and_fills_author(self): + base_book = BookRecord( + title="Комплексное ПТСР. Как справиться с гневом и страхом", + author="", + isbn="9785002143382", + ) + enriched_book = BookRecord( + title="Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук", + author="Тамара Макклинток-Гринберг", + isbn="9785002143382", + ) + + result = yandex._merge_result_page_book(base_book, enriched_book) + + assert result is not None + assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук" + assert result.author == "Тамара Макклинток-Гринберг" + + def test_merge_result_page_book_ignores_longer_storefront_title(self): + base_book = BookRecord( + title="Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук", + author="Тамара Макклинток-Гринберг", + isbn="9785002143382", + ) + enriched_book = BookRecord( + title="Книга издательства МИФ. Комплексное ПТСР, мягкая обложка (Макклинток-Гринберг Тамара) в Пинске", + author="Тамара Макклинток-Гринберг", + isbn="9785002143382", + ) + + result = yandex._merge_result_page_book(base_book, enriched_book) + + assert result is not None + assert result.title == "Комплексное ПТСР. Как справиться с гневом и страхом и вернуть идентичность. Воркбук" + + @pytest.mark.asyncio + async def test_missing_playwright_returns_none(self, monkeypatch): + monkeypatch.setenv("YANDEX_ENABLED", "1") + real_import = _builtins.__import__ + + def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0): + if name.startswith(("playwright", "playwright_stealth")): + raise ImportError(f"mocked missing {name}") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(_builtins, "__import__", fake_import) + async with httpx.AsyncClient() as client: + result = await yandex.fetch_from_yandex("9785961449136", client) + assert result is None + + # --------------------------------------------------------------------------- # Lookup orchestrator # --------------------------------------------------------------------------- @@ -404,3 +1057,235 @@ async def mock_ddg(isbn, client): result = await lookup.fetch_book_by_isbn("9781234567890") assert result is not None assert result.title == "From DDG" + + @pytest.mark.asyncio + async def test_playwright_site_search_runs_before_ddg(self, monkeypatch): + async def mock_none(isbn, client): + return None + + async def mock_site_search(isbn, client): + return BookRecord(title="From LiveLib", isbn=isbn) + + async def mock_ddg(isbn, client): + return BookRecord(title="From DDG", isbn=isbn) + + monkeypatch.setattr(lookup, "fetch_from_labirint", mock_none) + monkeypatch.setattr(lookup, "fetch_from_piter", mock_none) + monkeypatch.setattr(lookup, "fetch_from_google_books", mock_none) + monkeypatch.setattr(lookup, "fetch_from_yandex", mock_none) + monkeypatch.setattr(lookup, "fetch_from_playwright_site_search", mock_site_search) + monkeypatch.setattr(lookup, "fetch_from_ddg", mock_ddg) + + result = await lookup.fetch_book_by_isbn("9781234567890") + assert result is not None + assert result.title == "From LiveLib" + + @pytest.mark.asyncio + async def test_rsl_rkp_runs_first(self, monkeypatch): + """РГБ опрашивается первым в основной гонке.""" + + async def mock_none(isbn, client): + return None + + async def mock_rsl(isbn, client): + return BookRecord(title="From RSL", isbn=isbn) + + monkeypatch.setattr(lookup, "fetch_from_rsl_rkp", mock_rsl) + monkeypatch.setattr(lookup, "fetch_from_labirint", mock_none) + monkeypatch.setattr(lookup, "fetch_from_piter", mock_none) + monkeypatch.setattr(lookup, "fetch_from_google_books", mock_none) + monkeypatch.setattr(lookup, "fetch_from_yandex", mock_none) + + result = await lookup.fetch_book_by_isbn("9785171221836") + assert result is not None + assert result.title == "From RSL" + + +# --------------------------------------------------------------------------- +# RSL/РКП provider +# --------------------------------------------------------------------------- + + +class TestRslBibParser: + """Парсер библиографического описания ГОСТ 7.1, как в ответе РГБ.""" + + def test_full_description_parses_title_author_publisher_year(self): + text = ( + "Никто не заплачет : [роман : 16+] / Полина Дашкова. - Москва : " + "АСТ, cop. 2020. - 411, [2] с.; 22 см. - " + "(Полина Дашкова - лучшая среди лучших).; ISBN 978-5-17-122183-6 : 2000 экз." + ) + + parsed = rsl_rkp.parse_bibliographic_description(text) + + assert parsed["title"] == "Никто не заплачет" + assert parsed["author"] == "Полина Дашкова" + assert parsed["publisher"] == "АСТ" + assert parsed["year"] == "2020" + assert parsed["series"] == "Полина Дашкова - лучшая среди лучших" + + def test_multi_author_split_by_comma(self): + text = ( + "Nudge. Архитектура выбора : Архитектура выбора : " + "как улучшить наши решения о здоровье : [16+] / " + "Ричард Талер, Касс Санстейн ; пер. с англ. Е. Петровой. - " + "Москва : Манн, Иванов и Фербер, 2017. - 240 с." + ) + + parsed = rsl_rkp.parse_bibliographic_description(text) + + assert parsed["title"].startswith("Nudge") + assert parsed["author"] == "Ричард Талер, Касс Санстейн" + assert parsed["publisher"] == "Манн, Иванов и Фербер" + assert parsed["year"] == "2017" + + def test_no_author_block_returns_empty_author(self): + text = "Сборник стихов. - Москва : Эксмо, 2019. - 256 с." + + parsed = rsl_rkp.parse_bibliographic_description(text) + + assert parsed["title"] == "Сборник стихов" + assert parsed["author"] == "" + assert parsed["publisher"] == "Эксмо" + assert parsed["year"] == "2019" + + def test_no_year_yields_empty_year(self): + text = "Какая-то книга / Иван Иванов. - Москва : Альпина." + + parsed = rsl_rkp.parse_bibliographic_description(text) + + assert parsed["title"] == "Какая-то книга" + assert parsed["author"] == "Иван Иванов" + assert parsed["publisher"] == "Альпина" + assert parsed["year"] == "" + + def test_strips_material_marker_and_age_rating(self): + text = "Эра млекопитающих [Текст] : [12+] / Стив Брусатти. - Москва : МИФ, 2024." + + parsed = rsl_rkp.parse_bibliographic_description(text) + + assert parsed["title"] == "Эра млекопитающих" + assert parsed["author"] == "Стив Брусатти" + assert parsed["publisher"] == "МИФ" + assert parsed["year"] == "2024" + + +class TestRslContentParser: + """Извлечение карточек из поля ``content`` ответа ajax-search.""" + + def test_found_fixture_returns_one_card(self): + fixture = json.loads((FIXTURES_DIR / "rsl_search_found.json").read_text()) + + cards = rsl_rkp.parse_search_content(fixture["content"]) + + assert len(cards) == 1 + card = cards[0] + assert card["record_id"] == "01010444981" + assert "Никто не заплачет" in card["main_info"] + assert "Дашкова" in card["author_info"] + # Тема должна попасть в topics + assert "Художественная" in card["topic"] + + def test_not_found_fixture_returns_empty_list(self): + fixture = json.loads((FIXTURES_DIR / "rsl_search_notfound.json").read_text()) + + cards = rsl_rkp.parse_search_content(fixture["content"]) + + assert cards == [] + + +class TestFetchFromRslRkp: + """Интеграция fetch_from_rsl_rkp через httpx.MockTransport.""" + + @pytest.mark.asyncio + async def test_disabled_via_env_returns_none_without_network(self, monkeypatch): + monkeypatch.setenv("RSL_RKP_ENABLED", "0") + + def fail(_request: httpx.Request) -> httpx.Response: + msg = "сеть не должна вызываться при выключенном RSL_RKP" + raise AssertionError(msg) + + transport = httpx.MockTransport(fail) + async with httpx.AsyncClient(transport=transport) as client: + result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client) + + assert result is None + + @pytest.mark.asyncio + async def test_happy_path_returns_book_record(self, monkeypatch): + monkeypatch.delenv("RSL_RKP_ENABLED", raising=False) + fixture = json.loads((FIXTURES_DIR / "rsl_search_found.json").read_text()) + bootstrap_html = '<html><head><meta name="csrf-token" content="TEST_CSRF"></head><body></body></html>' + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + if request.method == "GET": + return httpx.Response(200, text=bootstrap_html) + assert request.method == "POST" + assert "ajax-search" in str(request.url) + return httpx.Response(200, json=fixture) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client) + + assert result is not None + assert "Никто не заплачет" in result.title + assert "Дашкова" in result.author + assert result.publisher == "АСТ" + assert result.isbn == "9785171221836" + assert result.link.endswith("/ru/record/01010444981") + methods = [r.method for r in seen] + assert methods.count("POST") == 1, "ровно один POST к ajax-search" + assert methods.count("GET") >= 1, "минимум один bootstrap GET" + # CSRF из bootstrap должен идти в POST + post_req = next(r for r in seen if r.method == "POST") + assert post_req.headers.get("X-CSRF-Token") == "TEST_CSRF" + body = post_req.read().decode() + assert "SearchFilterForm%5Bsearch%5D=9785171221836" in body + + @pytest.mark.asyncio + async def test_total_hits_zero_returns_none(self, monkeypatch): + monkeypatch.delenv("RSL_RKP_ENABLED", raising=False) + fixture = json.loads((FIXTURES_DIR / "rsl_search_notfound.json").read_text()) + bootstrap_html = '<html><meta name="csrf-token" content="X"></html>' + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + return httpx.Response(200, text=bootstrap_html) + return httpx.Response(200, json=fixture) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + result = await rsl_rkp.fetch_from_rsl_rkp("9785961433234", client) + + assert result is None + + @pytest.mark.asyncio + async def test_network_error_returns_none(self, monkeypatch): + monkeypatch.delenv("RSL_RKP_ENABLED", raising=False) + + def raise_error(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom") + + transport = httpx.MockTransport(raise_error) + async with httpx.AsyncClient(transport=transport) as client: + result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client) + + assert result is None + + @pytest.mark.asyncio + async def test_missing_csrf_returns_none(self, monkeypatch): + monkeypatch.delenv("RSL_RKP_ENABLED", raising=False) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + return httpx.Response(200, text="<html><head></head><body></body></html>") + return httpx.Response(200, json={"TotalHits": 0, "content": ""}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + result = await rsl_rkp.fetch_from_rsl_rkp("9785171221836", client) + + assert result is None