From 3836cc5d98ea7463fd08822eb3fdead932016529 Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:12:48 +0300 Subject: [PATCH 1/9] =?UTF-8?q?fix(ddg):=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20fallback=20=D0=B4=D0=BB=D1=8F=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20Alpina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Уточнил обработку и выбор результатов DDG для книг Alpina, чтобы бот стабильнее восстанавливал корректные название, автора и другие метаданные в сценариях, где прежний fallback часто возвращал неполные или неточные данные. Коммит собирает исходное улучшение и последующий lint-fix в один логический bugfix, чтобы история ветки отражала единственную пользовательскую проблему: слабое извлечение метаданных Alpina через DDG. --- home_library/providers/ddg.py | 104 +++++++++++++++++++++++++-- tests/test_home_library_providers.py | 86 ++++++++++++++++++++++ 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/home_library/providers/ddg.py b/home_library/providers/ddg.py index c0e3aa0..e613c99 100644 --- a/home_library/providers/ddg.py +++ b/home_library/providers/ddg.py @@ -4,6 +4,7 @@ и пытается извлечь название и автора из заголовков. """ +import html import logging import re from http import HTTPStatus @@ -14,9 +15,27 @@ from home_library.domain.models import BookRecord MIN_MEANINGFUL_TITLE_LENGTH = 3 +AUTHOR_NAME_PARTS_COUNT = 2 logger = logging.getLogger(__name__) +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) +AUTHOR_LABEL_PATTERN = re.compile( + r"\bавтор\s+(?P[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){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) + def _clean_ddg_title(raw_title: str) -> str: """Очищает заголовок результата DuckDuckGo от шумовых суффиксов. @@ -56,11 +75,64 @@ def _clean_ddg_title(raw_title: str) -> str: cleaned_title = cleaned_title.strip(config.STRIP_CHARS) # Убираем доменный префикс вроде "ozon.ru: " 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) # Убираем завершающие многоточия "..." - return config.TITLE_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: + """Нормализует автора, извлечённого из выдачи DuckDuckGo.""" + 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_NAME_PARTS_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 + + # После метки author имя часто приходит как "Фамилия Имя". + return f"{parts[1]} {parts[0]}" + + +def _extract_author_from_snippets(snippets: list[str]) -> str: + """Пытается извлечь автора из сниппетов результатов DuckDuckGo.""" + 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 _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: + +def _extract_ddg_book( + titles: list[str], + isbn: str, + snippets: list[str] | None = None, +) -> BookRecord | None: """Пытается извлечь книгу из набора заголовков результатов DuckDuckGo. Алгоритм: @@ -73,6 +145,7 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: Args: titles: Список заголовков результатов поиска. isbn: ISBN для записи в результат. + snippets: Список сниппетов результатов поиска для fallback по автору. Returns: ``BookRecord`` с извлечёнными данными или ``None``, @@ -118,7 +191,11 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: lowered = [title.lower() for title in cleaned] best = max( cleaned, - key=lambda candidate: (lowered.count(candidate.lower()), len(candidate)), + key=lambda candidate: ( + lowered.count(candidate.lower()), + -_title_noise_score(candidate), + len(candidate), + ), ) # Шаг 4: ищем автора из пар «автор — название» @@ -130,6 +207,8 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: # Если точного совпадения нет — берём первого автора из пар if not author and em_dash_parts: author = em_dash_parts[0][0] + if not author and snippets: + author = _extract_author_from_snippets(snippets) return BookRecord( title=best, @@ -176,15 +255,26 @@ async def fetch_from_ddg(isbn: str, client: httpx.AsyncClient) -> BookRecord | N ) return None - # Извлекаем текст заголовков из HTML результатов + # Извлекаем текст заголовков и сниппетов из HTML результатов. titles_html = re.findall( r'class="result__a"[^>]*>(.*?)]*>(.*?)', + resp.text, + re.DOTALL, + ) + 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_ddg_book(titles, isbn, snippets=snippets) except httpx.HTTPError as exc: logger.warning( diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 7117348..0e47767 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -244,6 +244,25 @@ def test_strips_trailing_ellipsis(self): def test_strips_multiple_separators(self): assert ddg._clean_ddg_title("Война и мир | Литрес - Ozon") == "Война и мир" + def test_strips_alpina_series_article_and_age_marks(self): + assert ( + ddg._clean_ddg_title( + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + ) + == "От 0 до 5.Простые подсказки для умных родителей" + ) + + def test_strips_article_suffix_without_alpina_prefix(self): + assert ddg._clean_ddg_title("Просто книга арт. 9785916717099") == "Просто книга" + + def test_strips_marketplace_prefix_and_edition_suffix(self): + assert ( + ddg._clean_ddg_title( + "Нехудожественная книга Альпина От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.", + ) + == "От 0 до 5: Простые подсказки для умных родителей" + ) + class TestExtractDdgBook: def test_with_author_via_emdash(self): @@ -267,6 +286,53 @@ def test_without_author(self): assert result.title == "Война и мир" assert result.author == "" + def test_extracts_author_from_snippet(self): + titles = [ + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...", + ] + snippets = [ + "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", + ] + + result = ddg._extract_ddg_book(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 = ddg._extract_ddg_book(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 = ddg._extract_ddg_book(titles, "9785916717099", snippets=snippets) + + assert result is not None + assert result.title == "От 0 до 5: Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + def test_non_cyrillic_filtered_returns_none(self): titles = [ "War and Peace - Amazon", @@ -301,6 +367,26 @@ async def test_success(self): assert result is not None assert result.title == "Война и мир" + @pytest.mark.asyncio + async def test_success_with_author_from_snippet(self): + html = """ + + + АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099 + Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина + + + """ + transport = httpx.MockTransport( + lambda req: httpx.Response(200, text=html), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await ddg.fetch_from_ddg("9785916717099", client) + + assert result is not None + assert result.title == "От 0 до 5.Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + @pytest.mark.asyncio async def test_http_error_returns_none(self): transport = httpx.MockTransport( From ee2d85482b8f5a323585d79f26e17a8cf5960657 Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:13:34 +0300 Subject: [PATCH 2/9] =?UTF-8?q?feature(lookup):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20Yandex=20fallback=20=D0=B8=20ISBN=20?= =?UTF-8?q?hint=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил дополнительный путь поиска книги через Yandex и Playwright для ISBN, которые не находятся в основных источниках. Это закрывает сценарии, где обычный lookup возвращает пустой результат, хотя книга существует в открытых каталогах или поисковой выдаче. Если автоматический поиск не находит книгу, бот теперь просит у пользователя короткую текстовую подсказку и повторяет поиск с контекстом. Такой flow снижает число тупиковых добавлений, делает поведение бота понятнее и помогает восстановить метаданные без ручного заполнения всей карточки. --- AGENTS.md | 9 + MEMORY.md | 5 +- README.md | 27 +- .../interfaces/telegram/handlers/_helpers.py | 13 + .../interfaces/telegram/handlers/callbacks.py | 58 +++- .../interfaces/telegram/handlers/commands.py | 29 ++ home_library/providers/_serp_parser.py | 214 ++++++++++++ home_library/providers/ddg.py | 324 +++++------------- home_library/providers/lookup.py | 7 +- home_library/providers/yandex.py | 190 ++++++++++ pyproject.toml | 4 + requirements-dev.txt | 6 + tests/test_home_library_e2e.py | 97 +++++- tests/test_home_library_providers.py | 215 +++++++++++- 14 files changed, 929 insertions(+), 269 deletions(-) create mode 100644 home_library/providers/_serp_parser.py create mode 100644 home_library/providers/yandex.py diff --git a/AGENTS.md b/AGENTS.md index 317fa09..92d96ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,15 @@ export LIBRARY_BOT_TOKEN="" export LIBRARY_BOT_ADMIN_USERNAME="" python -m home_library ``` + +Optional Yandex provider via Playwright (для ISBN, не находящихся в основных каталогах): +```bash +python -m pip install -r requirements-dev.txt # включает playwright, playwright-stealth +playwright install chromium +export YANDEX_ENABLED=1 +``` +Без `YANDEX_ENABLED` провайдер Yandex мгновенно возвращает `None` и не замедляет гонку. + Notes: - If `venv/` is absent, create it instead of assuming it exists. diff --git a/MEMORY.md b/MEMORY.md index 6154f84..1e21e21 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -22,7 +22,10 @@ - Бот сам создает `library.db` и `users.db`, если файлов нет. - `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`. - `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта. -- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> DuckDuckGo`. +- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> Yandex (опциональный) -> DuckDuckGo`. +- 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` как админский. diff --git a/README.md b/README.md index e8480e0..b066a94 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Home Library - Telegram-бот для учета домашней библиот - Бот распознает ISBN по фотографии. - Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки. - Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию. -- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books и DuckDuckGo. +- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. +- Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`. - Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки. - После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше). @@ -59,6 +60,30 @@ 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. + ## Первый Запуск - `LIBRARY_BOT_TOKEN` обязателен всегда. Получить токен можно у [@BotFather](https://t.me/BotFather). diff --git a/home_library/interfaces/telegram/handlers/_helpers.py b/home_library/interfaces/telegram/handlers/_helpers.py index 289fc6b..cb50359 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,17 @@ logger = logging.getLogger(__name__) +PENDING_ISBN_HINT_KEY = "pending_isbn_hint" + + +@dataclass(frozen=True) +class PendingIsbnHint: + """Состояние ожидания подсказки от пользователя по не найденному ISBN.""" + + isbn: 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..5b0b9b3 100644 --- a/home_library/interfaces/telegram/handlers/callbacks.py +++ b/home_library/interfaces/telegram/handlers/callbacks.py @@ -3,9 +3,11 @@ import hashlib import logging +import httpx from telegram import ( InlineKeyboardButton, InlineKeyboardMarkup, + Message, Update, ) from telegram.ext import ContextTypes @@ -15,7 +17,9 @@ from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers._helpers import ( EXPORT_CALLBACK_MIN_PARTS, + PENDING_ISBN_HINT_KEY, SET_CALLBACK_MIN_PARTS, + PendingIsbnHint, _parse_edit_callback_data, _require_callback_query, _require_message, @@ -25,6 +29,7 @@ ) from home_library.interfaces.telegram.keyboards import edit_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.storage.sqlite import ( add_book, @@ -173,11 +178,62 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No if not book_data: logger.info("ISBN %s: ни один провайдер не вернул данных", isbn) + user_data = _require_user_data(context) + user_data[PENDING_ISBN_HINT_KEY] = PendingIsbnHint(isbn=isbn) await message.reply_text( - f"Книга с ISBN {isbn} не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск.", + f"Книга с ISBN {isbn} не найдена.\n" + "Напиши название, автора или часть — попробую с подсказкой (или /cancel).", ) return + await _present_book_for_isbn(update, context, message, book_data, isbn) + + +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: + 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", pending.isbn) + await message.reply_text( + "Произошла ошибка при поиске по подсказке. Попробуй ещё раз.", + ) + return + + if not book_data: + await message.reply_text( + "По подсказке тоже ничего не нашлось. Попробуй другой ISBN или добавь книгу вручную.", + ) + return + + await _present_book_for_isbn(update, context, message, book_data, pending.isbn) + + +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: diff --git a/home_library/interfaces/telegram/handlers/commands.py b/home_library/interfaces/telegram/handlers/commands.py index 52c8ff9..96dac9a 100644 --- a/home_library/interfaces/telegram/handlers/commands.py +++ b/home_library/interfaces/telegram/handlers/commands.py @@ -13,7 +13,10 @@ 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_ISBN_HINT_KEY, + PendingIsbnHint, _build_export_keyboard, _format_telegram_username, _require_message, @@ -189,6 +192,9 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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 if user_data.pop("awaiting_access_username", None) is not None: await message.reply_text("Привязка Telegram username отменена.") return @@ -290,6 +296,23 @@ async def text_search(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> No await do_search(update, query) +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 handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Обрабатывает текстовый ввод при редактировании поля. @@ -300,6 +323,12 @@ async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) - return message = _require_message(update) user_data = _require_user_data(context) + + pending_hint = user_data.get(PENDING_ISBN_HINT_KEY) + if isinstance(pending_hint, PendingIsbnHint): + await _consume_pending_isbn_hint(update, context, message.text or "", pending_hint) + return + awaiting_access_username = user_data.get("awaiting_access_username") if awaiting_access_username is True: username = (message.text or "").strip() diff --git a/home_library/providers/_serp_parser.py b/home_library/providers/_serp_parser.py new file mode 100644 index 0000000..9fc0e71 --- /dev/null +++ b/home_library/providers/_serp_parser.py @@ -0,0 +1,214 @@ +"""Парсер поисковой выдачи (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 + +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( + r"\bавтор\s+(?P[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){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) + + +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 + + # Сниппеты каталогов часто отдают автора в формате «Фамилия Имя» — переворачиваем. + 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 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] = [] + em_dash_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()) + else: + cleaned.append(cleaned_title) + + if not cleaned: + return None + + 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 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 e613c99..7417b3a 100644 --- a/home_library/providers/ddg.py +++ b/home_library/providers/ddg.py @@ -1,7 +1,8 @@ """DuckDuckGo provider adapter. Ищет книгу по ISBN через HTML-версию DuckDuckGo, парсит результаты -и пытается извлечь название и автора из заголовков. +и пытается извлечь название и автора из заголовков. Логика разбора +вынесена в ``_serp_parser`` и переиспользуется с Yandex-провайдером. """ import html @@ -13,219 +14,76 @@ from home_library import config from home_library.domain.models import BookRecord - -MIN_MEANINGFUL_TITLE_LENGTH = 3 -AUTHOR_NAME_PARTS_COUNT = 2 +from home_library.providers._serp_parser import extract_book_from_serp logger = logging.getLogger(__name__) -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) -AUTHOR_LABEL_PATTERN = re.compile( - r"\bавтор\s+(?P[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)", - re.IGNORECASE, +DDG_SEARCH_URL = "https://html.duckduckgo.com/html/" +DDG_USER_AGENT = "Mozilla/5.0" + +RESULT_TITLE_PATTERN = re.compile( + r'class="result__a"[^>]*>(.*?)[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){1,2})\s*:\s*Books", +RESULT_SNIPPET_PATTERN = re.compile( + r'class="result__snippet"[^>]*>(.*?)', + re.DOTALL, ) -TITLE_NOISE_PATTERN = re.compile(r"/|\bизд\b", re.IGNORECASE) - - -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 +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) - # Отсекаем всё после разделителей магазинов - 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) - 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 = 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: - """Нормализует автора, извлечённого из выдачи DuckDuckGo.""" - 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_NAME_PARTS_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 - - # После метки author имя часто приходит как "Фамилия Имя". - return f"{parts[1]} {parts[0]}" - - -def _extract_author_from_snippets(snippets: list[str]) -> str: - """Пытается извлечь автора из сниппетов результатов DuckDuckGo.""" - 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, +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) - 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 _extract_ddg_book( - titles: list[str], - isbn: str, - snippets: list[str] | None = None, -) -> BookRecord | None: - """Пытается извлечь книгу из набора заголовков результатов DuckDuckGo. - - Алгоритм: - 1. Отбираем заголовки с кириллицей (русскоязычные книги). - 2. Очищаем каждый от шума через ``_clean_ddg_title``. - 3. Разбиваем заголовки с «—» на пару (автор, название). - 4. Выбираем наиболее частый очищенный заголовок как название. - 5. Автора берём из пар «автор — название», если есть. - - Args: - titles: Список заголовков результатов поиска. - isbn: ISBN для записи в результат. - snippets: Список сниппетов результатов поиска для fallback по автору. - - 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()), - -_title_noise_score(candidate), - 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] - if not author and snippets: - author = _extract_author_from_snippets(snippets) - - 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). @@ -240,55 +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"[^>]*>(.*?)]*>(.*?)', - resp.text, - re.DOTALL, - ) - 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_ddg_book(titles, isbn, snippets=snippets) - 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..bc286f0 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 (опционально) параллельно, +затем DuckDuckGo. """ import asyncio @@ -14,6 +15,7 @@ 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.yandex import fetch_from_yandex logger = logging.getLogger(__name__) @@ -41,10 +43,13 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None: logger.info("Начинаю поиск книги по ISBN %s", isbn) # Порядок приоритета: результат первого по индексу побеждает. + # Яндекс подключён опционально через env YANDEX_ENABLED=1 — без флага + # провайдер мгновенно возвращает None и не замедляет гонку. primary_providers = [ ("Лабиринт", fetch_from_labirint), ("Piter", fetch_from_piter), ("Google Books", fetch_from_google_books), + ("Яндекс", fetch_from_yandex), ] async with httpx.AsyncClient(timeout=10) as client: diff --git a/home_library/providers/yandex.py b/home_library/providers/yandex.py new file mode 100644 index 0000000..b355294 --- /dev/null +++ b/home_library/providers/yandex.py @@ -0,0 +1,190 @@ +"""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_SERVICE_TITLE_MARKERS = ( + "поиск isbn", + "search for books by isbn", + "search results", + "расширенный поиск", + "связанные запросы", +) + +YANDEX_CAPTCHA_MARKERS = ( + "вы не робот", + "smartcaptcha", + "captcha", +) + + +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, + ) + + +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] + 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 + + return _parse_yandex_serp(titles, snippets, isbn) 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/tests/test_home_library_e2e.py b/tests/test_home_library_e2e.py index fa4e369..af4fdd6 100644 --- a/tests/test_home_library_e2e.py +++ b/tests/test_home_library_e2e.py @@ -205,9 +205,102 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT assert driver.reply(1)["text"] == ( - "Книга с ISBN 9789999999999 не найдена.\nПроверил Лабиринт, Питер, Google Books и поиск." + "Книга с ISBN 9789999999999 не найдена.\n" + "Напиши название, автора или часть — попробую с подсказкой (или /cancel)." ) - assert driver.user_data == {} + pending_hint = driver.user_data.get("pending_isbn_hint") + assert pending_hint is not None + assert pending_hint.isbn == "9789999999999" + + +@pytest.mark.asyncio +async def test_isbn_hint_flow_resolves_book_via_ddg(test_db, monkeypatch) -> None: + """Пользователь подсказал название — книга находится через DDG с контекстом.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> None: + return None + + async def fake_fetch_from_ddg_with_context(isbn, hint, _client): + assert isbn == "9785961449136" + assert hint == "Мужские правила" + return BookRecord( + title="Мужские правила: Отношения, секс, психология", + author="Марк Мэнсон", + publisher="", + topics="", + link="", + isbn=isbn, + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785961449136") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + monkeypatch.setattr( + handlers.callbacks, + "fetch_from_ddg_with_context", + fake_fetch_from_ddg_with_context, + ) + driver = BotDriver() + + await driver.send_photo() + 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() + 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() + 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 diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 0e47767..6e408a7 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -1,10 +1,21 @@ """Tests for provider helpers.""" +import builtins as _builtins + import httpx import pytest 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, + yandex, +) # --------------------------------------------------------------------------- # Barcode @@ -231,47 +242,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 ( - ddg._clean_ddg_title( + _serp_parser._clean_title( "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", ) == "От 0 до 5.Простые подсказки для умных родителей" ) def test_strips_article_suffix_without_alpina_prefix(self): - assert ddg._clean_ddg_title("Просто книга арт. 9785916717099") == "Просто книга" + assert _serp_parser._clean_title("Просто книга арт. 9785916717099") == "Просто книга" def test_strips_marketplace_prefix_and_edition_suffix(self): assert ( - ddg._clean_ddg_title( + _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 == "Толстой" @@ -281,7 +306,7 @@ 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 == "" @@ -295,7 +320,11 @@ def test_extracts_author_from_snippet(self): "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", ] - result = ddg._extract_ddg_book(titles, "9785916717099", snippets=snippets) + result = _serp_parser.extract_book_from_serp( + titles, + "9785916717099", + snippets=snippets, + ) assert result is not None assert result.title == "От 0 до 5.Простые подсказки для умных родителей" @@ -307,7 +336,7 @@ def test_prefers_cleaner_title_when_frequency_is_equal(self): "Чистое название / служебный хвост изд ...", ] - result = ddg._extract_ddg_book(titles, "9785171234567") + result = _serp_parser.extract_book_from_serp(titles, "9785171234567") assert result is not None assert result.title == "Чистое название" @@ -327,7 +356,11 @@ def test_extracts_specific_alpina_non_fiction_book(self): "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд-во, Автор 0, книга, isbn:9785916717099,", ] - result = ddg._extract_ddg_book(titles, "9785916717099", snippets=snippets) + result = _serp_parser.extract_book_from_serp( + titles, + "9785916717099", + snippets=snippets, + ) assert result is not None assert result.title == "От 0 до 5: Простые подсказки для умных родителей" @@ -338,11 +371,11 @@ def test_non_cyrillic_filtered_returns_none(self): "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 @@ -416,6 +449,154 @@ def raise_error(_request: httpx.Request) -> httpx.Response: assert result is None +class TestFetchFromDdgWithContext: + @pytest.mark.asyncio + async def test_query_uses_isbn_plus_hint(self): + seen_queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_queries.append(request.url.params.get("q", "")) + return httpx.Response(200, text=DDG_SEARCH_HTML) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + await ddg.fetch_from_ddg_with_context( + "9785171234567", + "Мужские правила", + client, + ) + assert seen_queries == ["9785171234567 Мужские правила"] + + @pytest.mark.asyncio + async def test_empty_hint_falls_back_to_isbn_only(self): + seen_queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_queries.append(request.url.params.get("q", "")) + return httpx.Response(200, text=DDG_SEARCH_HTML) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + await ddg.fetch_from_ddg_with_context("9785171234567", " ", client) + assert seen_queries == ["9785171234567"] + + @pytest.mark.asyncio + async def test_alpina_9785961449136_regression(self): + html = """ + + + Марк Мэнсон — Мужские правила: Отношения, секс, психология + Марк Мэнсон. Мужские правила: Отношения, секс, психология. ISBN 9785961449136. + ozon.ru: Мужские правила: Отношения, секс, психология... + Автор Мэнсон Марк Издательство Альпина Паблишер ISBN 9785961449136 + Мужские правила: Отношения, секс, психология - Читай-город + Мужские правила: Отношения, секс, психология — Альпина Паблишер + Мужские правила: Отношения, секс, психология | Book24 + + + """ + transport = httpx.MockTransport( + lambda req: httpx.Response(200, text=html), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await ddg.fetch_from_ddg_with_context( + "9785961449136", + "Мужские правила", + client, + ) + assert result is not None + assert result.title == "Мужские правила: Отношения, секс, психология" + assert result.author == "Марк Мэнсон" + assert result.isbn == "9785961449136" + + @pytest.mark.asyncio + async def test_http_error_returns_none(self): + transport = httpx.MockTransport( + lambda req: httpx.Response(500), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await ddg.fetch_from_ddg_with_context( + "9785171234567", + "hint", + client, + ) + assert result is None + + +class TestParseYandexSerp: + def test_alpina_9785961449136_regression(self): + """Фикстура из живого прогона Playwright для проблемного ISBN.""" + titles = [ + "Поиск ISBN 9785961449136", + "Мужские правила Марк Мэнсон - купить в Альпина.", + "Мужские правила: Отношения, секс, психология Альпина...", + "Мужские правила Mark Manson", + ] + snippets = [ + "Мужские правила. Марк Мэнсон. ISBN 9785961449136.", + "Тип нехудожественная книга Автор Мэнсон Марк издательство Альпина", + ] + result = yandex._parse_yandex_serp(titles, snippets, "9785961449136") + assert result is not None + assert result.title == "Мужские правила: Отношения, секс, психология" + assert result.author == "Марк Мэнсон" + assert result.isbn == "9785961449136" + + def test_alpina_9785916717099_regression(self): + """Регрессия: книга, которая уже чинилась ранее через DDG.""" + titles = [ + "Search for books by ISBN 9785916717099", + "От 0 до 5: Простые подсказки для умных родителей", + "От 0 до 5: Простые подсказки для умных родителей", + "От 0 до 5 Альпина...", + ] + snippets = [ + "Советы Трейси основаны на рекомендациях специалистов.", + "Автор Катчлоу Трейси Издательство Альпина", + ] + result = yandex._parse_yandex_serp(titles, snippets, "9785916717099") + assert result is not None + assert result.title == "От 0 до 5: Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + + def test_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 + + @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 # --------------------------------------------------------------------------- From 20f7ea0816a99367d57b836351735ef80b306e97 Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:14:36 +0300 Subject: [PATCH 3/9] =?UTF-8?q?feature(scan-draft):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20flow=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?OCR-=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил полноценный экран проверки OCR-черновика после сканирования обложки и титульного листа. Пользователь может уточнить ISBN, автора и название до сохранения, а бот показывает промежуточную карточку книги вместо немедленного перехода к сохранению или поиску. Это уменьшает число ошибок после OCR, делает сценарий добавления книги управляемым и готовит данные к дальнейшему обогащению метаданными только после того, как пользователь подтвердил базовые поля. --- MEMORY.md | 2 + README.md | 42 +- .../interfaces/telegram/formatters.py | 18 + .../interfaces/telegram/handlers/__init__.py | 2 + .../interfaces/telegram/handlers/_helpers.py | 24 + .../interfaces/telegram/handlers/callbacks.py | 509 ++++++++++++-- .../interfaces/telegram/handlers/commands.py | 87 ++- .../interfaces/telegram/handlers/main.py | 1 + home_library/interfaces/telegram/keyboards.py | 33 + home_library/providers/title_page.py | 638 ++++++++++++++++++ tests/fixtures/title_page_chelomova.jpg | Bin 0 -> 237947 bytes tests/helpers/bot_driver.py | 2 + tests/test_home_library_e2e.py | 314 ++++++++- tests/test_home_library_providers.py | 180 +++++ 14 files changed, 1747 insertions(+), 105 deletions(-) create mode 100644 home_library/providers/title_page.py create mode 100644 tests/fixtures/title_page_chelomova.jpg diff --git a/MEMORY.md b/MEMORY.md index 1e21e21..5ca9975 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -23,6 +23,8 @@ - `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`. - `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта. - Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> Yandex (опциональный) -> DuckDuckGo`. +- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `PendingScanDraft` с полями `isbn`, `author`, `title`; пользователь может поправить их вручную или прислать фото оборота титульного листа. +- OCR оборота титульного листа вынесен в `home_library/providers/title_page.py`: используется `Pillow` для препроцессинга и системный `tesseract` (`rus+eng`) для распознавания текста; дальше срабатывают простые эвристики для `ISBN`, автора и названия. - Yandex-провайдер активируется только при `YANDEX_ENABLED=1` и установленных `playwright`+`playwright-stealth` (нужен `playwright install chromium`), иначе тихо возвращает `None`. - Общие паттерны чистки SERP-заголовков и извлечения автора вынесены в `home_library/providers/_serp_parser.py` и переиспользуются DDG и Yandex. - Если ни один провайдер не нашёл книгу, бот сохраняет `PendingIsbnHint` в `user_data["pending_isbn_hint"]`, просит подсказку у пользователя и повторяет поиск через `fetch_from_ddg_with_context` (DDG с `q = "{isbn} {hint}"`). `/cancel` сбрасывает ожидание подсказки. diff --git a/README.md b/README.md index b066a94..0264c53 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ Home Library - Telegram-бот для учета домашней библиот Обычный сценарий работы с ботом выглядит так: - Вы фотографируете штрихкод на задней стороне книги. -- Бот распознает ISBN по фотографии. -- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки. -- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию. +- Бот распознает ISBN по фотографии и сначала показывает черновик распознанных полей: `ISBN`, `Автор`, `Название`. +- Пользователь может исправить любое поле вручную перед поиском. +- Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год. +- Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки. - Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. - Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`. - Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки. @@ -22,6 +23,7 @@ Home Library - Telegram-бот для учета домашней библиот - отправьте `/start`, чтобы увидеть краткую справку - отправьте `/search <запрос>` или просто текстовое сообщение, чтобы найти книгу в каталоге - отправьте фото штрихкода, если хотите найти и добавить книгу по ISBN +- при необходимости пришлите фото оборота титульного листа, чтобы бот добрал автора, название и ISBN по выходным сведениям - используйте кнопки в боте, чтобы менять статус, оценку, комментарий, темы и местоположение - если вы админ, используйте `/add_access_to_library` для выдачи Telegram-доступа членам семьи - если вы админ, используйте `/export ` для выгрузки каталога @@ -43,6 +45,16 @@ python -m pip install --upgrade pip python -m pip install -r requirements.txt ``` +Для OCR оборота титульного листа дополнительно нужен системный `tesseract`. + +macOS: + +```bash +brew install tesseract tesseract-lang +``` + +Проверьте, что доступны языки `rus` и `eng`. Без `tesseract` бот всё равно будет работать, но OCR для оборота титульного листа будет недоступен. + Создайте файл `.env` в корне проекта и запишите в него следующее: ```dotenv @@ -84,6 +96,28 @@ python -m home_library сработает интерактивная подсказка, бот попросит название/автора и перезапустит поиск через DuckDuckGo. +## Фото Оборота Титульного Листа + +Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`. + +Подходящее фото обычно содержит выходные сведения, например: + +```text +Сью Алекс +System Design. Подготовка к сложному интервью. +СПб.: Питер, 2022 +ISBN 978-5-4461-1816-8 +ББК 32.973.2-02 +УДК 004.41 +``` + +Советы для OCR: + +- фотографируйте страницу целиком +- держите камеру ровно над страницей +- избегайте сильных теней и бликов +- лучше присылать именно оборот титульного листа, а не случайную первую страницу текста + ## Первый Запуск - `LIBRARY_BOT_TOKEN` обязателен всегда. Получить токен можно у [@BotFather](https://t.me/BotFather). @@ -191,7 +225,7 @@ python -m home_library ## Важно - `/search` и обычное текстовое сообщение ищут только по уже сохраненным книгам в каталоге домашней библиотеки -- внешний поиск используется только в сценарии добавления книги по фото штрихкода +- внешний поиск используется только в сценарии добавления книги по фото штрихкода или после подтверждения OCR-черновика - экспорт `/export db` отдает только `library.db`; `users.db` в экспорт не входит ## Файлы Данных diff --git a/home_library/interfaces/telegram/formatters.py b/home_library/interfaces/telegram/formatters.py index 7baf745..8c321eb 100644 --- a/home_library/interfaces/telegram/formatters.py +++ b/home_library/interfaces/telegram/formatters.py @@ -4,6 +4,7 @@ from home_library.config import BookEditableField, UserEditableField from home_library.domain.models import BookRecord, UserBookDataRecord +from home_library.interfaces.telegram.handlers._helpers import PendingScanDraft from home_library.storage.sqlite import get_user_by_id @@ -138,3 +139,20 @@ def get_field_label(field: str, user_id: int | None = None) -> str: return user_field.label return field + + +def format_scan_draft(draft: PendingScanDraft, *, from_verso: bool = False) -> str: + """Форматирует черновик распознанных полей для проверки пользователем.""" + title = "Распознал данные с оборота титульного листа:" if from_verso else "Что удалось распознать:" + isbn = escape(draft.isbn) if draft.isbn else "не найден" + author = escape(draft.author) if draft.author else "не найден" + book_title = escape(draft.title) if draft.title else "не найден" + tail = ( + "Всё верно?" + if from_verso + else ( + "Проверь данные перед поиском.\n" + "Если они неточные, можно исправить их вручную или прислать фото оборота титульного листа." + ) + ) + return f"{title}\n\nISBN: {isbn}\nАвтор: {author}\nНазвание: {book_title}\n\n{tail}" diff --git a/home_library/interfaces/telegram/handlers/__init__.py b/home_library/interfaces/telegram/handlers/__init__.py index eb36f24..a06ae19 100644 --- a/home_library/interfaces/telegram/handlers/__init__.py +++ b/home_library/interfaces/telegram/handlers/__init__.py @@ -30,6 +30,7 @@ handle_export_callback, handle_photo, handle_remove_access_callback, + handle_scan_draft_callback, handle_set_callback, ) @@ -86,6 +87,7 @@ "handle_export_callback", "handle_photo", "handle_remove_access_callback", + "handle_scan_draft_callback", "handle_set_callback", "log_unhandled_error", "main", diff --git a/home_library/interfaces/telegram/handlers/_helpers.py b/home_library/interfaces/telegram/handlers/_helpers.py index cb50359..14c2cff 100644 --- a/home_library/interfaces/telegram/handlers/_helpers.py +++ b/home_library/interfaces/telegram/handlers/_helpers.py @@ -22,6 +22,9 @@ logger = logging.getLogger(__name__) PENDING_ISBN_HINT_KEY = "pending_isbn_hint" +PENDING_SCAN_DRAFT_KEY = "pending_scan_draft" +SCAN_DRAFT_EDITING_KEY = "scan_draft_editing" +PENDING_BOOK_EDITING_KEY = "pending_book_editing" @dataclass(frozen=True) @@ -32,6 +35,27 @@ class PendingIsbnHint: created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) +@dataclass(slots=True) +class PendingScanDraft: + """Черновик данных книги, распознанных с фото до запуска поиска.""" + + token: str + isbn: str = "" + author: str = "" + title: str = "" + awaiting_verso_photo: bool = False + recognized_from_verso: bool = False + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass(slots=True) +class PendingBookEdit: + """Состояние ручной правки книги перед добавлением в каталог.""" + + field: str + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + EDIT_CALLBACK_MIN_PARTS = 3 OPTIONAL_USER_ID_INDEX = 3 SET_CALLBACK_MIN_PARTS = 5 diff --git a/home_library/interfaces/telegram/handlers/callbacks.py b/home_library/interfaces/telegram/handlers/callbacks.py index 5b0b9b3..e71263b 100644 --- a/home_library/interfaces/telegram/handlers/callbacks.py +++ b/home_library/interfaces/telegram/handlers/callbacks.py @@ -2,9 +2,12 @@ import hashlib import logging +import re +from typing import cast import httpx from telegram import ( + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, @@ -12,14 +15,20 @@ ) from telegram.ext import ContextTypes +from home_library import config from home_library.domain.models import BookRecord, EditState -from home_library.interfaces.telegram.formatters import escape, format_book, get_field_label +from home_library.interfaces.telegram.formatters import escape, format_book, format_scan_draft, get_field_label from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers._helpers import ( EXPORT_CALLBACK_MIN_PARTS, + PENDING_BOOK_EDITING_KEY, PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, SET_CALLBACK_MIN_PARTS, + PendingBookEdit, PendingIsbnHint, + PendingScanDraft, _parse_edit_callback_data, _require_callback_query, _require_message, @@ -27,19 +36,24 @@ _send_export_document, _update_context_repr, ) -from home_library.interfaces.telegram.keyboards import edit_keyboard +from home_library.interfaces.telegram.keyboards import edit_keyboard, pending_book_keyboard, scan_draft_keyboard from home_library.providers.barcode import decode_barcode from home_library.providers.ddg import fetch_from_ddg_with_context from home_library.providers.lookup import fetch_book_by_isbn +from home_library.providers.title_page import parse_title_page_image from home_library.storage.sqlite import ( add_book, find_existing_book, get_book_by_id, get_users, + search_books, update_book_field, ) logger = logging.getLogger(__name__) +SCAN_CALLBACK_MIN_PARTS = 3 +SCAN_CALLBACK_WITH_FIELD_PARTS = 4 +ADD_BOOK_CALLBACK_WITH_FIELD_PARTS = 3 def _make_pending_book_token(book: BookRecord) -> str: @@ -48,6 +62,276 @@ 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}) + + +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 _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: + """Продолжает flow после подтверждения черновика распознавания.""" + if not any((draft.isbn, draft.author, draft.title)): + await query.answer( + "Заполни хотя бы ISBN, автора или название перед поиском.", + show_alert=True, + ) + return + + _clear_scan_draft_state(user_data) + await query.edit_message_text("Проверил данные. Продолжаю поиск...") + + message = _require_callback_message(query) + normalized_isbn = re.sub(r"[^0-9Xx]", "", draft.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: + await _present_book_for_isbn(update, context, message, book_data, normalized_isbn) + return + + if draft.author or draft.title: + query_text = " ".join(part for part in [draft.author, draft.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 [draft.author, draft.title] if part).strip() + await message.reply_text("Ищу книгу по названию и автору...") + await _search_books_from_scan_draft(message, query_text) + + async def handle_export_callback( update: Update, _context: ContextTypes.DEFAULT_TYPE, @@ -131,10 +415,10 @@ async def handle_remove_access_callback( async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает фото со штрихкодом: распознаёт ISBN и ищет книгу. + """Обрабатывает фото: собирает черновик ISBN/автора/названия и запускает поиск позже. - Полный flow: фото → decode_barcode → fetch_book_by_isbn → - проверка дубликата → подтверждение добавления. + Полный flow: фото штрихкода/оборота титула → черновик распознавания → + подтверждение пользователя → поиск книги. Args: update: Telegram update с фото. @@ -155,38 +439,35 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No file = await photo.get_file() image_bytes = bytes(await file.download_as_bytearray()) - isbn = decode_barcode(image_bytes) - if not isbn: - await message.reply_text( - "Не удалось распознать штрихкод на фото.\nПопробуй сфотографировать его крупнее и ровнее.", - ) - return - - await message.reply_text( - f"📦 Найден ISBN: {escape(isbn)}\nИщу книгу...", - parse_mode="HTML", - ) - - try: - book_data = await fetch_book_by_isbn(isbn) - except Exception: - logger.exception("Ошибка при поиске книги по ISBN %s", isbn) - await message.reply_text( - f"Произошла ошибка при поиске книги по ISBN {isbn}.\nПопробуйте позже.", - ) - return + user_data = _require_user_data(context) + pending_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_draft, PendingScanDraft) and pending_draft.awaiting_verso_photo: + parsed_page = parse_title_page_image(image_bytes) + if parsed_page is None or not any((parsed_page.isbn, parsed_page.author, parsed_page.title)): + await message.reply_text( + "Не удалось распознать данные с оборота титульного листа.\n" + "Попробуй сфотографировать страницу целиком, ровно и без сильных теней.", + ) + return - if not book_data: - logger.info("ISBN %s: ни один провайдер не вернул данных", isbn) - user_data = _require_user_data(context) - user_data[PENDING_ISBN_HINT_KEY] = PendingIsbnHint(isbn=isbn) - await message.reply_text( - f"Книга с ISBN {isbn} не найдена.\n" - "Напиши название, автора или часть — попробую с подсказкой (или /cancel).", + merged_draft = _merge_title_page_into_draft( + pending_draft, + isbn=parsed_page.isbn, + author=parsed_page.author, + title=parsed_page.title, ) + user_data[PENDING_SCAN_DRAFT_KEY] = merged_draft + await _reply_with_scan_draft(message, merged_draft) return - await _present_book_for_isbn(update, context, message, book_data, isbn) + isbn = decode_barcode(image_bytes) or "" + draft = PendingScanDraft( + token=_make_scan_draft_token(isbn=isbn, author="", title=""), + isbn=isbn, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = draft + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await _reply_with_scan_draft(message, draft) async def handle_isbn_hint( @@ -226,6 +507,119 @@ async def handle_isbn_hint( await _present_book_for_isbn(update, context, message, book_data, pending.isbn) +async def handle_scan_draft_callback( # noqa: PLR0911 + update: Update, + context: ContextTypes.DEFAULT_TYPE, +) -> None: + """Обрабатывает inline-действия по проверке распознанных полей книги.""" + if not await _access._ensure_access(update): + return + query = _require_callback_query(update) + await query.answer() + user_data = _require_user_data(context) + parsed = _parse_scan_callback_data(query.data or "") + if parsed is None: + await query.edit_message_text("Ошибка: некорректные данные кнопки.") + return + + action, field, token = parsed + draft = _get_pending_scan_draft(user_data, token) + if draft is None: + await query.edit_message_text("Данные распознавания устарели. Отправь фото заново.") + return + + if action == "cancel": + _clear_scan_draft_state(user_data) + await query.edit_message_text("Отменено.") + return + + if action == "edit" and field in {"isbn", "author", "title"}: + await _prompt_scan_draft_field_edit(query, user_data, field) + return + + if action == "verso": + draft.awaiting_verso_photo = True + user_data[PENDING_SCAN_DRAFT_KEY] = draft + await query.edit_message_text(_build_title_page_verso_prompt()) + return + + if action != "continue": + await query.edit_message_text("Ошибка: неизвестное действие проверки данных.") + return + + if isinstance(user_data.get(SCAN_DRAFT_EDITING_KEY), str): + await _answer_edit_in_progress(query) + return + + await _continue_scan_draft(update, context, user_data, query, draft) + + +async def handle_scan_draft_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку поля в scan draft и снова показывает черновик.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if not isinstance(draft, PendingScanDraft): + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_isbn = cleaned_value if field == "isbn" else draft.isbn + updated_author = cleaned_value if field == "author" else draft.author + updated_title = cleaned_value if field == "title" else draft.title + updated = PendingScanDraft( + token=_make_scan_draft_token(isbn=updated_isbn, author=updated_author, title=updated_title), + isbn=updated_isbn, + author=updated_author, + title=updated_title, + recognized_from_verso=draft.recognized_from_verso, + ) + user_data[PENDING_SCAN_DRAFT_KEY] = updated + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_scan_draft(message, updated) + return True + + +async def handle_pending_book_field_input( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + value: str, +) -> bool: + """Сохраняет ручную правку preview книги и снова показывает её перед добавлением.""" + if field not in {"isbn", "author", "title"}: + return False + + user_data = _require_user_data(context) + pending_book = user_data.get("pending_book") + if not isinstance(pending_book, BookRecord): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + return False + + cleaned_value = value.strip() + if field == "isbn": + cleaned_value = re.sub(r"[^0-9Xx]", "", cleaned_value) + + updated_book = _build_pending_book(pending_book, field=field, value=cleaned_value) + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + + message = _require_message(update) + await _reply_with_pending_book_preview(message, updated_book, user_data) + return True + + async def _present_book_for_isbn( update: Update, context: ContextTypes.DEFAULT_TYPE, @@ -264,40 +658,17 @@ async def _present_book_for_isbn( return user_data = _require_user_data(context) - pending_book_token = _make_pending_book_token(book_data) - user_data["pending_book"] = book_data - user_data["pending_book_token"] = pending_book_token + user_data.pop(PENDING_BOOK_EDITING_KEY, None) logger.info( "Подготовлена книга к добавлению: title=%r, isbn=%r, for %s", book_data.title, isbn, _update_context_repr(update), ) + 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: @@ -314,15 +685,27 @@ 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 == "add_book_yes" and isinstance(user_data.get(PENDING_BOOK_EDITING_KEY), PendingBookEdit): + await _answer_edit_in_progress(query) + return + if action not in {"add_book_yes", "add_book_no"}: await query.edit_message_text("Ошибка: некорректные данные кнопки.") return @@ -331,10 +714,12 @@ 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( "Данные книги не найдены. Попробуй отправить фото заново.", diff --git a/home_library/interfaces/telegram/handlers/commands.py b/home_library/interfaces/telegram/handlers/commands.py index 96dac9a..c6bcf82 100644 --- a/home_library/interfaces/telegram/handlers/commands.py +++ b/home_library/interfaces/telegram/handlers/commands.py @@ -15,8 +15,13 @@ from home_library.interfaces.telegram.handlers import access as _access from home_library.interfaces.telegram.handlers import callbacks as _callbacks from home_library.interfaces.telegram.handlers._helpers import ( + PENDING_BOOK_EDITING_KEY, PENDING_ISBN_HINT_KEY, + PENDING_SCAN_DRAFT_KEY, + SCAN_DRAFT_EDITING_KEY, + PendingBookEdit, PendingIsbnHint, + PendingScanDraft, _build_export_keyboard, _format_telegram_username, _require_message, @@ -186,7 +191,7 @@ async def remove_access_to_library_command(update: Update, _context: ContextType await _access._show_remove_access_command(message) -async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # noqa: PLR0911 """Отменяет активный режим редактирования.""" if not await _access._ensure_access(update): return @@ -195,6 +200,17 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if user_data.pop(PENDING_ISBN_HINT_KEY, None) is not None: await message.reply_text("Ожидание подсказки по ISBN отменено.") return + pending_scan_draft = user_data.get(PENDING_SCAN_DRAFT_KEY) + if isinstance(pending_scan_draft, PendingScanDraft): + user_data.pop(PENDING_SCAN_DRAFT_KEY, None) + user_data.pop(SCAN_DRAFT_EDITING_KEY, None) + await message.reply_text("Проверка распознанных данных отменена.") + return + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + user_data.pop(PENDING_BOOK_EDITING_KEY, None) + await message.reply_text("Редактирование книги перед добавлением отменено.") + return if user_data.pop("awaiting_access_username", None) is not None: await message.reply_text("Привязка Telegram username отменена.") return @@ -313,25 +329,55 @@ async def _consume_pending_isbn_hint( await _callbacks.handle_isbn_hint(update, context, hint, pending) -async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обрабатывает текстовый ввод при редактировании поля. +async def _consume_scan_draft_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование полей scan draft.""" + return await _callbacks.handle_scan_draft_field_input(update, context, field, message_text) - Если пользователь не в режиме редактирования — обрабатывает - текст как поисковый запрос. - """ - if not await _access._ensure_access(update): - return + +async def _consume_pending_book_edit( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + field: str, + message_text: str, +) -> bool: + """Передаёт ручной ввод в редактирование preview книги перед добавлением.""" + return await _callbacks.handle_pending_book_field_input(update, context, field, message_text) + + +async def _handle_non_editing_text( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + message_text: str, +) -> bool: + """Обрабатывает текст вне режима DB-редактирования книги.""" message = _require_message(update) user_data = _require_user_data(context) pending_hint = user_data.get(PENDING_ISBN_HINT_KEY) if isinstance(pending_hint, PendingIsbnHint): - await _consume_pending_isbn_hint(update, context, message.text or "", pending_hint) - return + await _consume_pending_isbn_hint(update, context, message_text, pending_hint) + return True + + scan_draft_editing = user_data.get(SCAN_DRAFT_EDITING_KEY) + if isinstance(scan_draft_editing, str): + handled = await _consume_scan_draft_edit(update, context, scan_draft_editing, message_text) + if handled: + return True + + pending_book_editing = user_data.get(PENDING_BOOK_EDITING_KEY) + if isinstance(pending_book_editing, PendingBookEdit): + handled = await _consume_pending_book_edit(update, context, pending_book_editing.field, message_text) + if handled: + return True awaiting_access_username = user_data.get("awaiting_access_username") if awaiting_access_username is True: - username = (message.text or "").strip() + username = message_text.strip() admin_user = update.effective_user if admin_user is None: raise RuntimeError("Telegram update не содержит effective_user для настройки доступа.") @@ -342,13 +388,30 @@ async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) except ValueError as exc: await message.reply_text(f"Ошибка: {exc}") - return + return True user_data.pop("awaiting_access_username", None) await message.reply_text( f"Ожидаю первое сообщение от {_format_telegram_username(pending_link.expected_telegram_username)}.", reply_markup=_access._build_access_keyboard(), ) + return True + + return False + + +async def handle_edit_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Обрабатывает текстовый ввод при редактировании поля. + + Если пользователь не в режиме редактирования — обрабатывает + текст как поисковый запрос. + """ + if not await _access._ensure_access(update): + return + message = _require_message(update) + user_data = _require_user_data(context) + + if await _handle_non_editing_text(update, context, message.text or ""): return editing_value = user_data.get("editing") diff --git a/home_library/interfaces/telegram/handlers/main.py b/home_library/interfaces/telegram/handlers/main.py index 24c67a9..ea07139 100644 --- a/home_library/interfaces/telegram/handlers/main.py +++ b/home_library/interfaces/telegram/handlers/main.py @@ -108,6 +108,7 @@ def main() -> None: app.add_handler( CallbackQueryHandler(_callbacks.handle_add_book_callback, pattern="^add_book_"), ) + app.add_handler(CallbackQueryHandler(_callbacks.handle_scan_draft_callback, pattern="^scan:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_access_callback, pattern="^access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_remove_access_callback, pattern="^remove_access:")) app.add_handler(CallbackQueryHandler(_callbacks.handle_export_callback, pattern="^export:")) diff --git a/home_library/interfaces/telegram/keyboards.py b/home_library/interfaces/telegram/keyboards.py index 45eeb42..7672eeb 100644 --- a/home_library/interfaces/telegram/keyboards.py +++ b/home_library/interfaces/telegram/keyboards.py @@ -43,3 +43,36 @@ 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 "📖 Пришлю оборот титульного листа" + continue_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(continue_label, callback_data=f"scan:continue:{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_yes:{token}")], + [InlineKeyboardButton("❌ Отмена", callback_data=f"add_book_no:{token}")], + ] + return InlineKeyboardMarkup(buttons) diff --git a/home_library/providers/title_page.py b/home_library/providers/title_page.py new file mode 100644 index 0000000..2f7246d --- /dev/null +++ b/home_library/providers/title_page.py @@ -0,0 +1,638 @@ +"""OCR и эвристики для оборота титульного листа книги.""" + +import logging +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError + +from home_library import config + +logger = logging.getLogger(__name__) + +ISBN_PATTERN = re.compile( + r"(?i)\bisbn(?:-1[03])?[:\s]*([0-9xX\-\s]{10,20})", +) +LINE_SPACE_PATTERN = re.compile(r"\s+") +CONTROL_CODE_PATTERN = re.compile(r"^[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*$", re.IGNORECASE) +CONTROL_CODE_PREFIX_PATTERN = re.compile(r"^(?:[A-ZА-ЯЁ0-9]\s*[-–—]?\s*\d+[\d\-.]*)\s+", re.IGNORECASE) +TRAILING_FRAGMENT_PENALTY = 3 +TRAILING_FRAGMENT_MAX_LETTERS = 4 +NOISE_PREFIXES = ("ббк", "bbk", "удк", "udk", "©", "copyright", "права", "информация", "интервью", "глава") +NOISE_MIN_LETTERS = 4 +AUTHOR_MIN_WORDS = 2 +AUTHOR_MAX_LENGTH = 60 +OCR_FALLBACK_THRESHOLD = 170 +OCR_BOTTOM_CROPS = (0.5, 0.66) +OCR_TARGET_MAX_EDGE = 2400 +OCR_PSMS = ("3", "4", "6", "11") +OCR_TESSERACT_DPI = "300" +OCR_TESSERACT_OEM = "1" +AUTHOR_MIN_LETTER_RATIO = 0.55 +TITLE_MIN_LETTER_RATIO = 0.45 +SCORING_AUTHOR_MIN_WORDS = 2 +SCORING_AUTHOR_MAX_WORDS = 5 +SCORING_TITLE_MIN_LENGTH = 15 +SCORING_TITLE_MAX_LENGTH = 160 +SENTENCE_TERMINATORS = (".", ")", "!", "?", "”", "»") +JUNK_TOKEN_MAX_LETTERS = 2 +MIN_TERMINATORS_FOR_SEGMENT_STRIP = 2 +TAIL_SPLIT_PARTS = 2 +HEAD_SPLIT_PARTS = 2 +TRAILING_LAST_WORD_MAX_LETTERS = 1 +OCR_SCALE_NOOP_LOWER = 0.95 +OCR_SCALE_NOOP_UPPER = 1.05 +HYPHEN_LINE_END_PATTERN = re.compile(r"(.*\S)[-‐‑‒–—]\s*$") +LOWER_LETTER_START_PATTERN = re.compile(r"^[\sа-яёa-z]") +NON_WORD_PATTERN = re.compile(r"[^\w]", re.UNICODE) +YEAR_PATTERN = re.compile(r"\b(?:19|20)\d{2}\b") +PUBLISHING_MARKERS = ( + "спб", + "спб.", + "м.", + "москва", + "санкт-петербург", + "питер", + "изд", + "издательство", + "эксмо", + "ast", + "act", + "аст", + "альпина", + "манн", +) + + +@dataclass(slots=True) +class ParsedTitlePage: + """Результат OCR-разбора оборота титульного листа.""" + + isbn: str = "" + author: str = "" + title: str = "" + raw_text: str = "" + + +def _normalize_line(line: str) -> str: + """Очищает OCR-строку от лишних пробелов.""" + return LINE_SPACE_PATTERN.sub(" ", line.replace("\u00a0", " ")).strip() + + +def _is_junk_tail_token(token: str) -> bool: + """Определяет, выглядит ли токен как короткий OCR-мусор после конца предложения.""" + stripped = NON_WORD_PATTERN.sub("", token) + if not stripped: + return True + if any(char.isdigit() for char in stripped): + return False + return len(stripped) <= JUNK_TOKEN_MAX_LETTERS + + +def _strip_after_last_terminator(line: str) -> str | None: + """Если после последнего терминатора стоит мусор, обрезает его. Иначе возвращает ``None``.""" + positions = [index for index, char in enumerate(line) if char in SENTENCE_TERMINATORS] + if not positions: + return None + last_pos = positions[-1] + tail = line[last_pos + 1 :].strip() + if tail: + tokens = tail.split() + if tokens and all(_is_junk_tail_token(token) for token in tokens): + return line[: last_pos + 1].rstrip() + return None + if len(positions) >= MIN_TERMINATORS_FOR_SEGMENT_STRIP: + prev_pos = positions[-2] + segment_tokens = line[prev_pos + 1 : last_pos].split() + if segment_tokens and all(_is_junk_tail_token(token) for token in segment_tokens): + return line[: prev_pos + 1].rstrip() + return None + + +def _strip_trailing_short_word(line: str) -> str | None: + """Срезает однобуквенный или не-словесный хвостовой токен. Иначе возвращает ``None``.""" + tail_split = line.rsplit(maxsplit=1) + if len(tail_split) != TAIL_SPLIT_PARTS: + return None + last_word = NON_WORD_PATTERN.sub("", tail_split[1]) + if any(char.isdigit() for char in last_word): + return None + if not last_word or len(last_word) <= TRAILING_LAST_WORD_MAX_LETTERS: + return tail_split[0].rstrip() + return None + + +def _strip_ocr_tail_noise(line: str) -> str: + """Срезает короткий OCR-мусор с хвоста строки. + + Применяет в цикле две стратегии: срез после последнего терминатора и срез + одиночных коротких хвостовых токенов. + """ + line = line.rstrip() + while True: + stripped = _strip_after_last_terminator(line) + if stripped is None: + stripped = _strip_trailing_short_word(line) + if stripped is None: + return line + line = stripped + + +def _strip_ocr_lead_noise(line: str) -> str: + """Срезает однобуквенный OCR-мусор в начале строки.""" + while True: + head_split = line.lstrip().split(maxsplit=1) + if len(head_split) < HEAD_SPLIT_PARTS: + return line.lstrip() + first_word = NON_WORD_PATTERN.sub("", head_split[0]) + if not first_word: + line = head_split[1] + continue + if any(char.isdigit() for char in first_word): + return line.lstrip() + if len(first_word) <= 1: + line = head_split[1] + continue + return line.lstrip() + + +def _join_hyphenated_lines(lines: list[str]) -> list[str]: + """Склеивает строки, где предыдущая оканчивается на дефис, а следующая — на строчную букву.""" + if not lines: + return lines + result: list[str] = [] + skip_next = False + for index, current in enumerate(lines): + if skip_next: + skip_next = False + continue + match = HYPHEN_LINE_END_PATTERN.match(current) + if match and index + 1 < len(lines) and LOWER_LETTER_START_PATTERN.match(lines[index + 1]): + joined = match.group(1) + lines[index + 1].lstrip() + result.append(joined) + skip_next = True + continue + result.append(current) + return result + + +def _strip_control_code_prefix(line: str) -> str: + """Убирает библиотечный шифр из начала строки, если он есть.""" + return CONTROL_CODE_PREFIX_PATTERN.sub("", line).strip() + + +def _contains_letters(value: str) -> bool: + """Проверяет, есть ли в строке буквы.""" + return any(char.isalpha() for char in value) + + +def _letter_ratio(value: str) -> float: + """Возвращает долю букв среди значимых символов строки.""" + significant_chars = [char for char in value if not char.isspace()] + if not significant_chars: + return 0.0 + letters = sum(char.isalpha() for char in significant_chars) + return letters / len(significant_chars) + + +def _contains_cyrillic(value: str) -> bool: + """Проверяет, есть ли в строке кириллица.""" + return any("а" <= char.lower() <= "я" or char.lower() == "ё" for char in value) + + +def _normalize_isbn(candidate: str) -> str: + """Нормализует ISBN-кандидат до строки без дефисов и пробелов.""" + value = re.sub(r"[^0-9Xx]", "", candidate) + if len(value) == config.ISBN13_LENGTH and value.startswith(("978", "979")): + return value + if len(value) == config.ISBN10_LENGTH and (value.isdigit() or (value[:-1].isdigit() and value[-1] in "Xx")): + return value.upper() + return "" + + +def extract_isbn_from_text(text: str) -> str: + """Извлекает первый валидный ISBN из OCR-текста.""" + for match in ISBN_PATTERN.finditer(text): + normalized = _normalize_isbn(match.group(1)) + if normalized: + return normalized + + generic_candidates = re.findall(r"(?:97[89][\-\s]*)?(?:\d[\-\s]*){9,12}[\dXx]", text) + for candidate in generic_candidates: + normalized = _normalize_isbn(candidate) + if normalized: + return normalized + return "" + + +def _looks_like_noise(line: str) -> bool: + """Определяет, относится ли строка к шуму, а не к библиографическим данным.""" + lower = line.lower() + if not lower: + return True + if lower.startswith(NOISE_PREFIXES): + return True + if CONTROL_CODE_PATTERN.match(line): + return True + if lower in {"isbn", "автор", "название"}: + return True + letters = sum(1 for char in line if char.isalpha()) + has_digit = any(char.isdigit() for char in line) + return letters < NOISE_MIN_LETTERS and not has_digit + + +def _strip_publishing_tail(line: str) -> str: + """Отрезает хвост с издательскими данными от строки заглавия.""" + chunks = re.split(r"\s+[—-]\s+", line) + kept: list[str] = [] + for chunk in chunks: + lower = chunk.lower() + if any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(lower): + break + kept.append(chunk) + if kept: + return " — ".join(kept).strip(" .") + return line.strip(" .") + + +def _looks_like_author_line(line: str) -> bool: + """Проверяет, похожа ли строка на имя автора.""" + stripped = _strip_control_code_prefix(line) + return ( + not any(char.isdigit() for char in stripped) and AUTHOR_MIN_WORDS <= len(stripped.split()) <= AUTHOR_MAX_LENGTH + ) + + +def _extract_title_candidate(line: str) -> str: + """Очищает строку заглавия от шифра, автора и издательского хвоста.""" + cleaned = _strip_control_code_prefix(line) + title_part = cleaned.split("/", 1)[0] + return re.sub(r"\s+", " ", _strip_publishing_tail(title_part)).strip(" .") + + +def _looks_like_bibliographic_line(line: str) -> bool: + """Определяет, похожа ли строка на библиографическое описание рядом с ISBN.""" + stripped = _strip_control_code_prefix(line) + lower = stripped.lower() + return "/" in stripped and ( + any(marker in lower for marker in PUBLISHING_MARKERS) or YEAR_PATTERN.search(stripped) is not None + ) + + +def _extract_from_bibliographic_lines(section: list[str]) -> tuple[str, str]: + """Ищет автора и название в библиографической строке рядом с ISBN.""" + for index in range(len(section) - 1, -1, -1): + line = section[index] + if "/" not in line: + continue + + bibliographic_line = next( + ( + candidate + for candidate in _bibliographic_candidates(section, index) + if _looks_like_bibliographic_line(candidate) + ), + None, + ) + if bibliographic_line is None: + continue + + title = _extract_title_candidate(bibliographic_line) + if not title: + continue + + for candidate in reversed(section[max(0, index - 3) : index]): + normalized_candidate = _strip_control_code_prefix(candidate).strip(" .") + if _looks_like_author_line(normalized_candidate): + return normalized_candidate, title + return "", title + return "", "" + + +def _bibliographic_candidates(section: list[str], index: int) -> list[str]: + """Возвращает варианты строк для проверки на библиографическое описание.""" + candidates = [section[index]] + if index + 1 < len(section): + candidates.append(" ".join(section[index : index + 2])) + if index + 2 < len(section): + candidates.append(" ".join(section[index : index + 3])) + return candidates + + +def _collect_meaningful_lines(raw_text: str) -> tuple[list[str], list[str]]: + """Возвращает значимые строки и их подмножество до строки с ISBN.""" + normalized = [_normalize_line(line) for line in raw_text.splitlines()] + non_noise = [line for line in normalized if line and not _looks_like_noise(line)] + cleaned = [_strip_ocr_tail_noise(_strip_ocr_lead_noise(line)) for line in non_noise] + joined = _join_hyphenated_lines([line for line in cleaned if line]) + meaningful_lines = [line for line in joined if not _looks_like_noise(line)] + lines_before_isbn: list[str] = [] + for line in meaningful_lines: + if "isbn" in line.lower(): + break + lines_before_isbn.append(line) + return meaningful_lines, lines_before_isbn + + +def _extract_author_and_title(section: list[str]) -> tuple[str, str]: + """Пытается извлечь автора и название из верхнего блока OCR-строк.""" + bibliographic_author, bibliographic_title = _extract_from_bibliographic_lines(section) + if bibliographic_title: + return bibliographic_author, bibliographic_title + + author = "" + title = "" + + for index, line in enumerate(section): + if not _looks_like_author_line(line): + continue + author = _strip_control_code_prefix(line).strip(" .") + tail_lines = [_strip_control_code_prefix(item) for item in section[index + 1 : index + 4]] + if tail_lines: + title = _extract_title_candidate(" ".join(tail_lines)) + break + + if not title: + title_candidates = [ + _extract_title_candidate(line) for line in section if _strip_control_code_prefix(line).strip(" .") != author + ] + title_candidates = [line for line in title_candidates if line] + if title_candidates: + title = title_candidates[0] + return author, re.sub(r"\s+", " ", title).strip(" .") + + +def _is_low_quality_author(author: str) -> bool: + """Отбрасывает явный OCR-мусор в поле автора.""" + lower = author.lower() + if not author or not _contains_letters(author): + return True + if "_" in author or " or " in lower: + return True + return _letter_ratio(author) < AUTHOR_MIN_LETTER_RATIO + + +def _is_low_quality_title(title: str) -> bool: + """Отбрасывает явный OCR-мусор в поле названия.""" + lower = title.lower() + if not title or not _contains_letters(title): + return True + if "_" in title or any(marker in lower for marker in ("isbn", "удк", "ббк")): + return True + return _letter_ratio(title) < TITLE_MIN_LETTER_RATIO + + +def _sanitize_parsed_title_page(parsed: ParsedTitlePage) -> ParsedTitlePage: + """Очищает распознанные поля от низкокачественного OCR-мусора.""" + author = "" if _is_low_quality_author(parsed.author) else parsed.author + title = "" if _is_low_quality_title(parsed.title) else parsed.title + if author == parsed.author and title == parsed.title: + return parsed + return ParsedTitlePage(isbn=parsed.isbn, author=author, title=title, raw_text=parsed.raw_text) + + +def _trailing_fragment_penalty(text: str) -> int: + """Штраф за хвостовой OCR-фрагмент: короткое последнее слово, начинающееся со строчной буквы.""" + parts = text.rsplit(maxsplit=1) + if len(parts) < TAIL_SPLIT_PARTS: + return 0 + last_word = NON_WORD_PATTERN.sub("", parts[1]) + if not last_word: + return 0 + if any(char.isdigit() for char in last_word): + return 0 + if len(last_word) <= TRAILING_FRAGMENT_MAX_LETTERS and last_word[0].islower(): + return TRAILING_FRAGMENT_PENALTY + return 0 + + +def _score_parsed_title_page(parsed: ParsedTitlePage) -> int: + """Оценивает качество распознанного кандидата для выбора лучшего OCR-прохода.""" + score = 0 + if parsed.isbn: + score += 10 + if parsed.author: + score += 8 + if _contains_cyrillic(parsed.author): + score += 2 + if SCORING_AUTHOR_MIN_WORDS <= len(parsed.author.split()) <= SCORING_AUTHOR_MAX_WORDS: + score += 1 + score -= _trailing_fragment_penalty(parsed.author) + if parsed.title: + score += 8 + if _contains_cyrillic(parsed.title): + score += 2 + if SCORING_TITLE_MIN_LENGTH <= len(parsed.title) <= SCORING_TITLE_MAX_LENGTH: + score += 2 + score -= _trailing_fragment_penalty(parsed.title) + return score + + +def _select_best_parsed_title_page(text_candidates: list[str]) -> ParsedTitlePage | None: + """Выбирает лучший распознанный результат из нескольких OCR-кандидатов.""" + best_parsed: ParsedTitlePage | None = None + best_score = -1 + for candidate in text_candidates: + parsed = parse_title_page_text(candidate) + if parsed is None: + continue + sanitized = _sanitize_parsed_title_page(parsed) + score = _score_parsed_title_page(sanitized) + if score > best_score: + best_parsed = sanitized + best_score = score + return best_parsed + + +def parse_title_page_text(text: str) -> ParsedTitlePage | None: + """Пытается извлечь ISBN, автора и название из OCR-текста.""" + raw_text = text.strip() + if not raw_text: + return None + + isbn = extract_isbn_from_text(raw_text) + meaningful_lines, lines_before_isbn = _collect_meaningful_lines(raw_text) + if not meaningful_lines and not isbn: + return None + + section = lines_before_isbn or meaningful_lines[:6] + author, title = _extract_author_and_title(section) + author = re.sub(r"\s+", " ", author).strip(" .") + + if not any((isbn, author, title)): + return None + return _sanitize_parsed_title_page(ParsedTitlePage(isbn=isbn, author=author, title=title, raw_text=raw_text)) + + +def _open_image(image_bytes: bytes) -> Image.Image | None: + """Открывает изображение для OCR.""" + try: + return Image.open(BytesIO(image_bytes)) + except (OSError, UnidentifiedImageError): + logger.warning("Не удалось открыть изображение для OCR титульного листа (%d байт)", len(image_bytes)) + return None + + +def _image_to_png_bytes(image: Image.Image) -> bytes: + """Сохраняет PIL-изображение в PNG-байты.""" + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _otsu_threshold(image: Image.Image) -> int: + """Считает оптимальный бинарный порог по гистограмме (метод Оцу).""" + histogram = image.histogram()[:256] + total = sum(histogram) + if total == 0: + return OCR_FALLBACK_THRESHOLD + sum_total = sum(intensity * histogram[intensity] for intensity in range(256)) + sum_b = 0.0 + weight_b = 0 + max_variance = -1.0 + threshold = OCR_FALLBACK_THRESHOLD + for intensity in range(256): + weight_b += histogram[intensity] + if weight_b == 0: + continue + weight_f = total - weight_b + if weight_f == 0: + break + sum_b += intensity * histogram[intensity] + mean_b = sum_b / weight_b + mean_f = (sum_total - sum_b) / weight_f + between = weight_b * weight_f * (mean_b - mean_f) ** 2 + if between > max_variance: + max_variance = between + threshold = intensity + return threshold + + +def _scaled_to_target(image: Image.Image) -> Image.Image: + """Приводит изображение к целевому максимальному ребру для OCR.""" + width, height = image.size + longest_edge = max(width, height) + if longest_edge == 0: + return image + scale = OCR_TARGET_MAX_EDGE / longest_edge + if OCR_SCALE_NOOP_LOWER < scale < OCR_SCALE_NOOP_UPPER: + return image + new_size = (max(1, round(width * scale)), max(1, round(height * scale))) + return image.resize(new_size, Image.Resampling.LANCZOS) + + +def _binarize(image: Image.Image, threshold: int) -> Image.Image: + """Бинаризует grayscale-изображение по порогу.""" + return image.point(lambda pixel: 255 if pixel > threshold else 0) + + +def _preprocess_image_variants(image_bytes: bytes) -> list[bytes]: + """Готовит несколько вариантов изображения для OCR.""" + image = _open_image(image_bytes) + if image is None: + return [] + + grayscale = ImageOps.grayscale(image) + contrasted = ImageOps.autocontrast(grayscale, cutoff=1) + base = _scaled_to_target(contrasted) + sharpened = base.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=3)) + denoised = base.filter(ImageFilter.MedianFilter(size=3)) + + otsu_threshold_value = _otsu_threshold(base) + otsu = _binarize(sharpened, otsu_threshold_value) + fallback = _binarize(base, OCR_FALLBACK_THRESHOLD) + denoised_otsu = _binarize(denoised, _otsu_threshold(denoised)) + + variants_with_full_page: list[Image.Image] = [base, sharpened, otsu, fallback, denoised_otsu] + crop_variants: list[Image.Image] = [] + for crop_top_ratio in OCR_BOTTOM_CROPS: + crop_top = int(base.height * crop_top_ratio) + cropped = sharpened.crop((0, crop_top, sharpened.width, sharpened.height)) + crop_variants.append(_binarize(cropped, _otsu_threshold(cropped))) + + variants = [_image_to_png_bytes(image_variant) for image_variant in [*variants_with_full_page, *crop_variants]] + unique_variants: list[bytes] = [] + for variant in variants: + if variant not in unique_variants: + unique_variants.append(variant) + return unique_variants + + +def _run_tesseract(image_bytes: bytes, *, tesseract_path: str, psm: str) -> str | None: + """Запускает Tesseract для одного подготовленного варианта изображения.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_file.write(image_bytes) + temp_path = Path(temp_file.name) + + try: + completed = subprocess.run( # noqa: S603 + [ + tesseract_path, + str(temp_path), + "stdout", + "-l", + "rus+eng", + "--oem", + OCR_TESSERACT_OEM, + "--dpi", + OCR_TESSERACT_DPI, + "--psm", + psm, + ], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return None + finally: + temp_path.unlink(missing_ok=True) + + if completed.returncode != 0: + return None + + text = completed.stdout.strip() + return text or None + + +def _extract_text_candidates_from_title_page(image_bytes: bytes) -> list[str]: + """Запускает OCR в нескольких режимах и возвращает уникальные текстовые кандидаты.""" + prepared_variants = _preprocess_image_variants(image_bytes) + if not prepared_variants: + return [] + + tesseract_path = shutil.which("tesseract") + if tesseract_path is None: + logger.warning("tesseract не установлен, OCR оборота титульного листа недоступен") + return [] + + candidates: list[str] = [] + for prepared in prepared_variants: + for psm in OCR_PSMS: + text = _run_tesseract(prepared, tesseract_path=tesseract_path, psm=psm) + if text and text not in candidates: + candidates.append(text) + return candidates + + +def extract_text_from_title_page(image_bytes: bytes) -> str | None: + """Запускает системный Tesseract и возвращает OCR-текст страницы.""" + text_candidates = _extract_text_candidates_from_title_page(image_bytes) + if not text_candidates: + return None + + best_parsed = _select_best_parsed_title_page(text_candidates) + if best_parsed is not None: + return best_parsed.raw_text + return text_candidates[0] + + +def parse_title_page_image(image_bytes: bytes) -> ParsedTitlePage | None: + """Выполняет OCR изображения и разбирает распознанный текст.""" + return _select_best_parsed_title_page(_extract_text_candidates_from_title_page(image_bytes)) diff --git a/tests/fixtures/title_page_chelomova.jpg b/tests/fixtures/title_page_chelomova.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a47f905c58b2e615446dc65d5cca0e3ae2d2adc0 GIT binary patch literal 237947 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

eE7e;RaI#V>t+om4yEJ==ijvCQOW` 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

a7=m)@_sU#URw6Sd5 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℞M0 FakeCallbackQuery: if callback_data.startswith("add_book_"): await handlers.handle_add_book_callback(update, self.context) + elif callback_data.startswith("scan:"): + await handlers.handle_scan_draft_callback(update, self.context) elif callback_data.startswith("edit:"): await handlers.handle_edit_callback(update, self.context) elif callback_data.startswith("set:"): diff --git a/tests/test_home_library_e2e.py b/tests/test_home_library_e2e.py index af4fdd6..caefbf6 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,20 @@ 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_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 +98,21 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + continue_callback = reply_markup.inline_keyboard[3][0].callback_data + + callback = await driver.click(continue_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert "📦 Найден ISBN" in driver.reply(0)["text"] - assert "Книга уже есть в каталоге" in driver.reply(1)["text"] - assert "Взрослые дети эмоционально незрелых родителей" in driver.reply(1)["text"] + assert callback.answered is True + assert callback.edits[0]["text"] == "Проверил данные. Продолжаю поиск..." + assert "Книга уже есть в каталоге" in driver.last_reply_text() + assert "Взрослые дети эмоционально незрелых родителей" in driver.last_reply_text() assert "pending_book" not in driver.user_data @pytest.mark.asyncio async def test_handle_photo_adds_new_book_by_barcode_after_confirmation(test_db, monkeypatch) -> None: - """Проверяет полный flow добавления новой книги по ISBN.""" + """Проверяет полный flow: черновик -> поиск по ISBN -> подтверждение добавления.""" async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: return make_book( @@ -123,14 +129,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"] + continue_callback = scan_reply_markup.inline_keyboard[3][0].callback_data + + await driver.click(continue_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[2][0].callback_data callback = await driver.click(confirm_callback) @@ -141,8 +150,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 +178,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_continue_callback = first_reply_markup.inline_keyboard[3][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_continue_callback = second_reply_markup.inline_keyboard[3][0].callback_data - stale_callback = await driver.click(stale_confirm_callback) + stale_callback = await driver.click(stale_continue_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_continue_callback) assert current_callback.answered is True - assert "✅ Книга добавлена в каталог" in current_callback.edits[0]["text"] - assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is not None + assert current_callback.edits[0]["text"] == "Проверил данные. Продолжаю поиск..." + assert "Найдена книга" in driver.last_reply_text() + assert db.find_existing_book("Clean Code", "Robert C. Martin", isbn="9780132350884") is None @pytest.mark.asyncio async def test_handle_photo_reports_missing_book_outside_db(test_db, monkeypatch) -> None: - """Проверяет отрицательную ветку ISBN вне каталога и вне провайдеров.""" + """После подтверждения черновика бот уходит в fallback с ISBN hint.""" async def fake_fetch_book_by_isbn(_isbn: str) -> None: return None @@ -202,9 +212,12 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + continue_callback = reply_markup.inline_keyboard[3][0].callback_data + + await driver.click(continue_callback) - assert len(driver.replies()) == PHOTO_FLOW_REPLY_COUNT - assert driver.reply(1)["text"] == ( + assert driver.last_reply_text() == ( "Книга с ISBN 9789999999999 не найдена.\n" "Напиши название, автора или часть — попробую с подсказкой (или /cancel)." ) @@ -242,6 +255,8 @@ async def fake_fetch_from_ddg_with_context(isbn, hint, _client): driver = BotDriver() await driver.send_photo() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_callback) assert driver.user_data.get("pending_isbn_hint") is not None await driver.send_text("Мужские правила") @@ -275,6 +290,8 @@ async def fake_fetch_from_ddg_with_context(_isbn, _hint, _client): driver = BotDriver() await driver.send_photo() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_callback) await driver.send_text("Подсказка") assert driver.last_reply_text() == ( @@ -295,6 +312,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_callback) assert driver.user_data.get("pending_isbn_hint") is not None await driver.cancel() @@ -303,6 +322,247 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: assert driver.last_reply_text() == "Ожидание подсказки по ISBN отменено." +@pytest.mark.asyncio +async def test_scan_draft_accepts_title_page_verso_photo_and_manual_edit(test_db, monkeypatch) -> None: + """Второе фото оборота титульного листа дополняет черновик и допускает ручную правку.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785446118168", + author="Сью Алекс", + title="System Design. Подготовка к сложному интервью", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert "Автор: Сью Алекс" in driver.last_reply_text() + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Сью Алекс" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.send_text("Алекс Сью") + + assert "Автор: Алекс Сью" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + assert driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].text == "📖 Прислать другое фото оборота" + + +@pytest.mark.asyncio +async def test_scan_draft_accepts_manual_edit_for_isbn_and_title_after_verso(test_db, monkeypatch) -> None: + """После OCR с оборота можно вручную исправить ISBN и название без потери verso-режима.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + edit_isbn_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785042195730" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-04-219573-1") + + assert "ISBN: 9785042195731" in driver.last_reply_text() + assert "Распознал данные с оборота титульного листа:" in driver.last_reply_text() + + edit_title_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "Книготерапия : научно доказанный метод самопомощи" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("Книготерапия") + + assert "Название: Книготерапия" in driver.last_reply_text() + assert "Всё верно?" in driver.last_reply_text() + + +@pytest.mark.asyncio +async def test_scan_draft_blocks_continue_while_field_edit_is_active(test_db, monkeypatch) -> None: + """Во время ручной правки scan draft кнопка continue не должна запускать поиск.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785042195730") + driver = BotDriver() + + await driver.send_photo() + reply_markup = driver.reply()["kwargs"]["reply_markup"] + continue_callback = reply_markup.inline_keyboard[3][0].callback_data + edit_author_callback = reply_markup.inline_keyboard[0][1].callback_data + + await driver.click(edit_author_callback) + callback = await driver.click(continue_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() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_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[2][0].callback_data + await driver.click(edit_isbn_callback) + assert driver.last_edit_text() == "9785446118168" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("978-5-4461-1816-0") + assert "📦 ISBN: 9785446118160" in driver.last_reply_text() + + updated_preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_title_callback = updated_preview_markup.inline_keyboard[1][0].callback_data + await driver.click(edit_title_callback) + assert driver.last_edit_text() == "System Design. Подготовка к сложному интервью" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + await driver.send_text("System Design") + assert "📖 System Design" in driver.last_reply_text() + + stale_callback = await driver.click(stale_confirm_callback) + assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново." + + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][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_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() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + confirm_callback = preview_markup.inline_keyboard[2][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() + continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + await driver.click(continue_callback) + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + assert driver.last_edit_text() == "Роберт Мартин" + assert driver.last_reply_text() == "Введи новое значение:\n(или /cancel для отмены)" + + await driver.cancel() + + assert driver.user_data.get("pending_book_editing") is None + assert driver.last_reply_text() == "Редактирование книги перед добавлением отменено." + + +@pytest.mark.asyncio +async def test_cancel_clears_pending_scan_draft(test_db, monkeypatch) -> None: + """/cancel сбрасывает ожидание проверки распознанных данных.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + driver = BotDriver() + + await driver.send_photo() + assert driver.user_data.get("pending_scan_draft") is not None + + await driver.cancel() + + assert driver.user_data.get("pending_scan_draft") is None + assert driver.last_reply_text() == "Проверка распознанных данных отменена." + + @pytest.mark.asyncio async def test_start_users_stats_and_add_library_member_commands(test_db, monkeypatch) -> None: """Проверяет базовые команды бота на реальной временной БД.""" diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 6e408a7..d7d5e87 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -1,9 +1,13 @@ """Tests for provider helpers.""" import builtins as _builtins +import io +import shutil +from pathlib import Path import httpx import pytest +from PIL import Image from home_library.domain.models import BookRecord from home_library.providers import ( @@ -14,9 +18,16 @@ labirint, lookup, piter, + title_page, yandex, ) +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +OTSU_THRESHOLD_MIN = 50 +OTSU_THRESHOLD_MAX = 200 +PREPROCESS_MIN_VARIANTS = 5 + # --------------------------------------------------------------------------- # Barcode # --------------------------------------------------------------------------- @@ -30,6 +41,175 @@ def test_empty_bytes_returns_none(self): assert barcode.decode_barcode(b"") is None +class TestTitlePageParsing: + def test_extract_isbn_from_text(self): + text = "ISBN 978-5-4461-1816-8\nББК 32.973.2-02" + + assert title_page.extract_isbn_from_text(text) == "9785446118168" + + def test_parse_title_page_text_extracts_author_title_and_isbn(self): + text = ( + "C98\n" + "Сью Алекс\n" + "System Design. Подготовка к сложному интервью. — СПб.: Питер, 2022.\n" + "304 с.: ил.\n" + "ISBN 978-5-4461-1816-8\n" + "ББК 32.973.2-02\n" + "УДК 004.41\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785446118168" + assert parsed.author == "Сью Алекс" + assert parsed.title == "System Design. Подготовка к сложному интервью" + + def test_parse_title_page_text_returns_none_for_noise(self): + text = "ББК 32.973.2-02\nУДК 004.41\n© Byte Code LLC" + + assert title_page.parse_title_page_text(text) is None + + def test_parse_title_page_text_strips_control_code_from_title(self): + text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + "Чтение может исцелять, вдохновлять и менять жизнь.\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_parse_title_page_text_prefers_bibliographic_block_near_isbn(self): + text = ( + "УДК 159.9\n" + "ББК 88.52\n" + "П21\n" + "Marianne Power\n" + "HELP ME!\n" + "One woman's quest to find out if self-help\n" + "really can change her life\n" + "Copyright © Marianne Power 2018\n" + "Пауэр, Мэриэнн.\n" + "П21 Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново / Мэриэнн Пауэр; [перевод с английского Я.О. Мышкиной]. - Москва : Эксмо, 2022. - 448 с.\n" + "ISBN 978-5-04-161694-6\n" + ) + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785041616946" + assert parsed.author == "Пауэр, Мэриэнн" + assert ( + parsed.title + == "Какая чушь. Как 12 книг по психологии сначала разрушили мою жизнь, а потом собрали ее заново" + ) + + def test_parse_title_page_text_discards_low_quality_author_and_title(self): + text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + + parsed = title_page.parse_title_page_text(text) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "" + assert parsed.title == "" + + def test_parse_title_page_image_prefers_best_ocr_candidate(self, monkeypatch): + noisy_text = "ISBN 978-5-04-219573-0\n_ or _\ner... YAK 159.92 в. .. №: by" + clean_text = ( + "УДК 159.92\n" + "ББК 88.5\n" + "Ч-39\n" + "Челомова, Надежда Алексеевна.\n" + "Ч-39 Книготерапия : научно доказанный метод самопомощи / Надежда Челомова. - Москва : Эксмо, 2025. - 320 с. - (Книжная терапия).\n" + "ISBN 978-5-04-219573-0\n" + ) + + monkeypatch.setattr( + title_page, + "_extract_text_candidates_from_title_page", + lambda _image_bytes: [noisy_text, clean_text], + ) + + parsed = title_page.parse_title_page_image(b"image-bytes") + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert parsed.author == "Челомова, Надежда Алексеевна" + assert parsed.title == "Книготерапия : научно доказанный метод самопомощи" + + def test_strip_ocr_tail_noise_drops_short_garbage_after_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. и: ОЕ") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_garbage_between_two_terminators(self): + cleaned = title_page._strip_ocr_tail_noise("Челомова, Надежда Алексеевна. Е.") + + assert cleaned == "Челомова, Надежда Алексеевна." + + def test_strip_ocr_tail_noise_drops_trailing_single_letter_without_terminator(self): + cleaned = title_page._strip_ocr_tail_noise("Книготерапия / Надежда Чело- В") + + assert cleaned == "Книготерапия / Надежда Чело-" + + def test_strip_ocr_lead_noise_drops_single_letter_prefix(self): + cleaned = title_page._strip_ocr_lead_noise("j Ч-39 Книготерапия : научно доказанный") + + assert cleaned == "Ч-39 Книготерапия : научно доказанный" + + def test_join_hyphenated_lines_merges_word_split(self): + joined = title_page._join_hyphenated_lines( + ["Надежда Чело-", "мова. — Москва : Эксмо, 2025"], + ) + + assert joined == ["Надежда Челомова. — Москва : Эксмо, 2025"] + + def test_otsu_threshold_returns_value_between_min_and_max(self): + gradient = Image.new("L", (256, 1)) + for x in range(256): + gradient.putpixel((x, 0), x) + + threshold = title_page._otsu_threshold(gradient) + + assert OTSU_THRESHOLD_MIN < threshold < OTSU_THRESHOLD_MAX + + def test_preprocess_image_variants_includes_multiple_unique_variants(self): + image = Image.new("L", (200, 280), color=255) + for x in range(200): + for y in range(140, 280): + image.putpixel((x, y), 0) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + + variants = title_page._preprocess_image_variants(buffer.getvalue()) + + assert len(variants) >= PREPROCESS_MIN_VARIANTS + + @pytest.mark.skipif( + shutil.which("tesseract") is None, + reason="tesseract is not installed in this environment", + ) + def test_parse_title_page_image_handles_chelomova_photo(self): + image_path = FIXTURES_DIR / "title_page_chelomova.jpg" + + parsed = title_page.parse_title_page_image(image_path.read_bytes()) + + assert parsed is not None + assert parsed.isbn == "9785042195730" + assert "Челомова" in parsed.author + assert "Книготерапия" in parsed.title + + # --------------------------------------------------------------------------- # Google Books # --------------------------------------------------------------------------- From 805d4bc441025890cb6e75ee3c5c159c6dc8ff8d Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:14:36 +0300 Subject: [PATCH 4/9] =?UTF-8?q?feature(add-book):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20lookup=20=D0=B8=D0=B7=20previe?= =?UTF-8?q?w=20=D1=81=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разделил подтверждение OCR-черновика на два осознанных действия: мгновенное добавление книги по подтвержденным данным и отдельный поиск внешних метаданных. Это ускоряет быстрый сценарий добавления и убирает лишние внешние запросы, когда OCR уже дал достаточно точный результат. Также добавил повторный lookup из preview перед сохранением книги. Данные провайдера теперь только дополняют карточку и не перетирают вручную подтвержденные ISBN, автора и название, поэтому пользователь не теряет свои исправления после OCR. --- .../interfaces/telegram/handlers/callbacks.py | 212 +++++++++++++----- home_library/interfaces/telegram/keyboards.py | 9 +- tests/test_home_library_e2e.py | 185 ++++++++++++--- 3 files changed, 313 insertions(+), 93 deletions(-) diff --git a/home_library/interfaces/telegram/handlers/callbacks.py b/home_library/interfaces/telegram/handlers/callbacks.py index e71263b..955964b 100644 --- a/home_library/interfaces/telegram/handlers/callbacks.py +++ b/home_library/interfaces/telegram/handlers/callbacks.py @@ -136,6 +136,26 @@ def _build_pending_book(book: BookRecord, *, field: str, value: str) -> BookReco 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, @@ -153,6 +173,52 @@ async def _reply_with_pending_book_preview( ) +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], @@ -283,19 +349,43 @@ async def _continue_scan_draft( query: CallbackQuery, draft: PendingScanDraft, ) -> None: - """Продолжает flow после подтверждения черновика распознавания.""" - if not any((draft.isbn, draft.author, draft.title)): + """Ищет данные книги по текущим полям 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 - _clear_scan_draft_state(user_data) - await query.edit_message_text("Проверил данные. Продолжаю поиск...") + if clear_scan_draft: + _clear_scan_draft_state(user_data) + user_data.pop(PENDING_ISBN_HINT_KEY, None) + await query.edit_message_text("Ищу информацию о книге...") - message = _require_callback_message(query) - normalized_isbn = re.sub(r"[^0-9Xx]", "", draft.isbn) + normalized_isbn = re.sub(r"[^0-9Xx]", "", manual_book.isbn) if normalized_isbn: await message.reply_text( f"Ищу книгу по ISBN {escape(normalized_isbn)}...", @@ -311,11 +401,12 @@ async def _continue_scan_draft( return if book_data: - await _present_book_for_isbn(update, context, message, book_data, normalized_isbn) + 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 draft.author or draft.title: - query_text = " ".join(part for part in [draft.author, draft.title] if part).strip() + 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 @@ -327,7 +418,7 @@ async def _continue_scan_draft( ) return - query_text = " ".join(part for part in [draft.author, draft.title] if part).strip() + 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) @@ -543,15 +634,48 @@ async def handle_scan_draft_callback( # noqa: PLR0911 await query.edit_message_text(_build_title_page_verso_prompt()) return - if action != "continue": - await query.edit_message_text("Ошибка: неизвестное действие проверки данных.") - return - if isinstance(user_data.get(SCAN_DRAFT_EDITING_KEY), str): await _answer_edit_in_progress(query) return - await _continue_scan_draft(update, context, user_data, query, draft) + 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( @@ -702,10 +826,28 @@ async def handle_add_book_callback( # noqa: PLR0911 await _prompt_pending_book_field_edit(query, user_data, field) return - if action == "add_book_yes" and isinstance(user_data.get(PENDING_BOOK_EDITING_KEY), PendingBookEdit): + 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 @@ -726,43 +868,7 @@ async def handle_add_book_callback( # noqa: PLR0911 ) 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/keyboards.py b/home_library/interfaces/telegram/keyboards.py index 7672eeb..c6a53eb 100644 --- a/home_library/interfaces/telegram/keyboards.py +++ b/home_library/interfaces/telegram/keyboards.py @@ -48,7 +48,6 @@ def edit_keyboard(book_id: int) -> InlineKeyboardMarkup: def scan_draft_keyboard(token: str, *, from_verso: bool = False) -> InlineKeyboardMarkup: """Строит inline-клавиатуру для проверки распознанных полей книги.""" verso_label = "📖 Прислать другое фото оборота" if from_verso else "📖 Пришлю оборот титульного листа" - continue_label = "➡️ Всё верно, продолжить" if from_verso else "➡️ Продолжить" buttons = [ [ InlineKeyboardButton("✏️ ISBN", callback_data=f"scan:edit:isbn:{token}"), @@ -56,10 +55,9 @@ def scan_draft_keyboard(token: str, *, from_verso: bool = False) -> InlineKeyboa ], [InlineKeyboardButton("✏️ Название", callback_data=f"scan:edit:title:{token}")], [InlineKeyboardButton(verso_label, callback_data=f"scan:verso:{token}")], - [ - InlineKeyboardButton(continue_label, callback_data=f"scan:continue:{token}"), - InlineKeyboardButton("❌ Отмена", callback_data=f"scan:cancel:{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) @@ -72,6 +70,7 @@ def pending_book_keyboard(token: str) -> InlineKeyboardMarkup: 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}")], ] diff --git a/tests/test_home_library_e2e.py b/tests/test_home_library_e2e.py index caefbf6..9732675 100644 --- a/tests/test_home_library_e2e.py +++ b/tests/test_home_library_e2e.py @@ -81,9 +81,50 @@ async def test_handle_photo_reports_unreadable_barcode(test_db, monkeypatch) -> 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( @@ -99,12 +140,12 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: await driver.send_photo() reply_markup = driver.reply()["kwargs"]["reply_markup"] - continue_callback = reply_markup.inline_keyboard[3][0].callback_data + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data - callback = await driver.click(continue_callback) + callback = await driver.click(lookup_callback) assert callback.answered is True - assert callback.edits[0]["text"] == "Проверил данные. Продолжаю поиск..." + 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 @@ -112,7 +153,7 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: @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( @@ -130,16 +171,16 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: await driver.send_photo() scan_reply_markup = driver.reply()["kwargs"]["reply_markup"] - continue_callback = scan_reply_markup.inline_keyboard[3][0].callback_data + lookup_callback = scan_reply_markup.inline_keyboard[4][0].callback_data - await driver.click(continue_callback) + await driver.click(lookup_callback) 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()["kwargs"]["reply_markup"] - confirm_callback = reply_markup.inline_keyboard[2][0].callback_data + confirm_callback = reply_markup.inline_keyboard[3][0].callback_data callback = await driver.click(confirm_callback) @@ -151,7 +192,7 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: @pytest.mark.asyncio async def test_handle_photo_rejects_stale_scan_draft_confirmation(test_db, monkeypatch) -> None: - """Старая кнопка черновика не должна запускать поиск по новым данным.""" + """Старая кнопка поиска из черновика не должна запускать поиск по новым данным.""" books_by_isbn = { "9785446110773": make_book( title="Чистая архитектура", @@ -179,30 +220,30 @@ async def fake_fetch_book_by_isbn(isbn: str) -> BookRecord: await driver.send_photo() first_reply_markup = driver.reply()["kwargs"]["reply_markup"] - stale_continue_callback = first_reply_markup.inline_keyboard[3][0].callback_data + stale_lookup_callback = first_reply_markup.inline_keyboard[4][0].callback_data await driver.send_photo() second_reply_markup = driver.reply()["kwargs"]["reply_markup"] - current_continue_callback = second_reply_markup.inline_keyboard[3][0].callback_data + current_lookup_callback = second_reply_markup.inline_keyboard[4][0].callback_data - stale_callback = await driver.click(stale_continue_callback) + stale_callback = await driver.click(stale_lookup_callback) assert stale_callback.answered is True 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_continue_callback) + current_callback = await driver.click(current_lookup_callback) assert current_callback.answered is True - assert current_callback.edits[0]["text"] == "Проверил данные. Продолжаю поиск..." + 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: - """После подтверждения черновика бот уходит в fallback с ISBN hint.""" + """По кнопке поиска из черновика бот уходит в fallback с ISBN hint.""" async def fake_fetch_book_by_isbn(_isbn: str) -> None: return None @@ -213,9 +254,9 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: await driver.send_photo() reply_markup = driver.reply()["kwargs"]["reply_markup"] - continue_callback = reply_markup.inline_keyboard[3][0].callback_data + lookup_callback = reply_markup.inline_keyboard[4][0].callback_data - await driver.click(continue_callback) + await driver.click(lookup_callback) assert driver.last_reply_text() == ( "Книга с ISBN 9789999999999 не найдена.\n" @@ -255,8 +296,8 @@ async def fake_fetch_from_ddg_with_context(isbn, hint, _client): driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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("Мужские правила") @@ -290,8 +331,8 @@ async def fake_fetch_from_ddg_with_context(_isbn, _hint, _client): driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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() == ( @@ -312,8 +353,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> None: driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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() @@ -402,20 +443,56 @@ async def test_scan_draft_accepts_manual_edit_for_isbn_and_title_after_verso(tes assert "Всё верно?" in driver.last_reply_text() +@pytest.mark.asyncio +async def test_scan_draft_confirm_after_verso_edit_adds_book_directly(test_db, monkeypatch) -> None: + """После OCR с оборота и ручной правки автора подтверждение сразу добавляет книгу.""" + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "") + driver = BotDriver() + + await driver.send_photo() + verso_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + await driver.click(verso_callback) + + monkeypatch.setattr( + handlers.callbacks, + "parse_title_page_image", + lambda _image_bytes: ParsedTitlePage( + isbn="9785042195730", + author="Челомова, Надежда Алексеевна", + title="Книготерапия : научно доказанный метод самопомощи", + raw_text="raw", + ), + ) + await driver.send_photo(payload=b"verso") + + edit_author_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + await driver.send_text("Челомова Надежда Алексеевна") + + confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data + callback = await driver.click(confirm_callback) + + assert "✅ Книга добавлена в каталог" in callback.edits[0]["text"] + assert "Челомова Надежда Алексеевна" in callback.edits[0]["text"] + assert "Добавить в каталог?" not in callback.edits[0]["text"] + assert driver.user_data.get("pending_book") is None + + @pytest.mark.asyncio async def test_scan_draft_blocks_continue_while_field_edit_is_active(test_db, monkeypatch) -> None: - """Во время ручной правки scan draft кнопка continue не должна запускать поиск.""" + """Во время ручной правки 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"] - continue_callback = reply_markup.inline_keyboard[3][0].callback_data + 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(continue_callback) + callback = await driver.click(lookup_callback) assert callback.answer_kwargs == { "text": "Сначала заверши редактирование поля.", @@ -441,8 +518,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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 @@ -455,7 +532,7 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: 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[2][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 для отмены)" @@ -473,7 +550,7 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: stale_callback = await driver.click(stale_confirm_callback) assert stale_callback.edits[0]["text"] == "Данные книги устарели. Попробуй отправить фото заново." - confirm_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + 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"] @@ -482,6 +559,44 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: assert "9785446118160" in callback.edits[0]["text"] +@pytest.mark.asyncio +async def test_pending_book_lookup_preserves_manual_fields(test_db, monkeypatch) -> None: + """Поиск из preview дополняет книгу, но не затирает ручные правки.""" + + async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: + return make_book( + title="System Design. Подготовка к сложному интервью", + author="Сью Алекс", + publisher="Питер", + isbn="9785446118168", + ) + + monkeypatch.setattr(handlers.callbacks, "decode_barcode", lambda _image_bytes: "9785446118168") + monkeypatch.setattr(handlers.callbacks, "fetch_book_by_isbn", fake_fetch_book_by_isbn) + driver = BotDriver() + + await driver.send_photo() + lookup_scan_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[4][0].callback_data + await driver.click(lookup_scan_callback) + + preview_markup = driver.reply()["kwargs"]["reply_markup"] + edit_author_callback = preview_markup.inline_keyboard[0][1].callback_data + await driver.click(edit_author_callback) + await driver.send_text("Алекс Сью") + + lookup_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[2][0].callback_data + callback = await driver.click(lookup_callback) + + assert callback.edits[0]["text"] == "Ищу информацию о книге..." + assert "📖 System Design. Подготовка к сложному интервью" in driver.last_reply_text() + assert "✍️ Алекс Сью" in driver.last_reply_text() + assert "🏢 Питер" in driver.last_reply_text() + pending_book = driver.user_data.get("pending_book") + assert isinstance(pending_book, BookRecord) + assert pending_book.author == "Алекс Сью" + assert pending_book.publisher == "Питер" + + @pytest.mark.asyncio async def test_pending_book_preview_blocks_add_while_field_edit_is_active(test_db, monkeypatch) -> None: """Во время ручной правки preview книги кнопка добавления не должна срабатывать.""" @@ -499,11 +614,11 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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[2][0].callback_data + 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) @@ -533,8 +648,8 @@ async def fake_fetch_book_by_isbn(_isbn: str) -> BookRecord: driver = BotDriver() await driver.send_photo() - continue_callback = driver.reply()["kwargs"]["reply_markup"].inline_keyboard[3][0].callback_data - await driver.click(continue_callback) + 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) From 2deeda86e7d6a3306f50d80aed8a4c78e5f6d298 Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:15:16 +0300 Subject: [PATCH 5/9] =?UTF-8?q?refactor(memory):=20=D0=B7=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил в MEMORY.md рабочие договоренности по разнесению больших наборов изменений на тематические ветки, атомарности коммитов и формату commit message. Это нужно, чтобы новые правила не остались только в обсуждении и использовались как постоянная часть проектной памяти. Отдельно зафиксировал ожидание к body коммита и PR: человеческое объяснение того, что изменилось, какую проблему это решает и зачем выбран именно такой способ реализации. --- MEMORY.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index 5ca9975..5751e78 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -48,6 +48,15 @@ - Для тестов SQLite путь к БД подменяется через `monkeypatch` в `tests/conftest.py`. - Для новых фич придерживаться test-first подхода: сначала тесты, потом реализация. +## Git Workflow Notes + +- Большие наборы изменений сначала раскладывать по отдельным тематическим веткам, а не копить в одной длинной feature-ветке. +- По возможности держать одну пользовательскую или техническую задачу в одной ветке и в одном PR. +- Коммиты делать атомарными: один логический шаг, в идеале одно изменение в одном файле или в тесно связанном наборе файлов. +- Формат commit message: `feature(): <короткое описание>`, `fix(): <короткое описание>`, `refactor(): <короткое описание>`. +- В body коммита и описании PR писать понятное человеку объяснение: что изменилось, какую проблему это решает и зачем выбран именно такой вариант. +- Если новая ветка зависит от другой тематической ветки, явно сохранять эту зависимость в базе ветки, а не прятать ее в конфликтах при переносе. + ## Pending Architectural Work - Хэндлеры разбиты на `commands.py`, `callbacks.py`, `access.py`, `_helpers.py` и `main.py`. From 2359e4d51156cd516b0aa7e3fe40958fa0dbcffe Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Tue, 5 May 2026 07:11:45 +0300 Subject: [PATCH 6/9] =?UTF-8?q?fix(lookup):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20Yandex=20=D0=B8=20SERP=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D1=8B=D1=85=20ISBN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Усилил общий парсер поисковой выдачи и Yandex fallback для кейсов, где книга видна человеку в поиске, но бот теряет автора или возвращает усеченное название. Добавлен разбор формата 'Автор: Название', более аккуратный выбор полного title вместо обрезанных вариантов и добор метаданных с верхних внешних страниц выдачи. Это нужно для проблемных ISBN вроде 9785002143382, где поисковик уже показывает релевантную книгу, но прежние эвристики отдавали неполные или шумные данные. В коммит также входят регрессионные тесты и отладочный Playwright-скрипт для живой проверки Yandex path. --- home_library/providers/_serp_parser.py | 62 ++++++++++- home_library/providers/yandex.py | 145 ++++++++++++++++++++++++- scripts/yandex_lookup_debug.py | 101 +++++++++++++++++ tests/test_home_library_providers.py | 106 ++++++++++++++++++ 4 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 scripts/yandex_lookup_debug.py diff --git a/home_library/providers/_serp_parser.py b/home_library/providers/_serp_parser.py index 9fc0e71..4f09f19 100644 --- a/home_library/providers/_serp_parser.py +++ b/home_library/providers/_serp_parser.py @@ -14,6 +14,7 @@ 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( @@ -28,13 +29,16 @@ re.IGNORECASE, ) AUTHOR_LABEL_PATTERN = re.compile( - r"\bавтор\s+(?P[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)", + 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: @@ -98,6 +102,8 @@ def _normalize_author(author: str, *, from_author_label: bool = False) -> str: 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]}" @@ -128,6 +134,42 @@ 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, @@ -169,7 +211,9 @@ def extract_book_from_serp( 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: @@ -178,12 +222,21 @@ def extract_book_from_serp( 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, @@ -201,6 +254,13 @@ def extract_book_from_serp( 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) diff --git a/home_library/providers/yandex.py b/home_library/providers/yandex.py index b355294..895c6b0 100644 --- a/home_library/providers/yandex.py +++ b/home_library/providers/yandex.py @@ -36,6 +36,9 @@ 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", @@ -51,6 +54,20 @@ "captcha", ) +YANDEX_INTERNAL_HOST_MARKERS = ( + "yandex.", + "ya.ru", +) + +YANDEX_TITLE_NOISE_MARKERS = ( + "издательства", + "обложка", + "отзывы", + "рецензии", + "краткое содержание", + "где купить", +) + def _looks_like_captcha(title: str, html_text: str) -> bool: """Определяет, что Yandex показал страницу SmartCaptcha.""" @@ -89,6 +106,121 @@ def _parse_yandex_serp( ) +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, @@ -168,6 +300,17 @@ async def fetch_from_yandex( "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: @@ -186,5 +329,3 @@ async def fetch_from_yandex( exc, ) return None - - return _parse_yandex_serp(titles, snippets, isbn) 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/test_home_library_providers.py b/tests/test_home_library_providers.py index d7d5e87..b726b7c 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -546,6 +546,38 @@ def test_extracts_specific_alpina_non_fiction_book(self): 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", @@ -739,6 +771,30 @@ def test_alpina_9785916717099_regression(self): 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", @@ -761,6 +817,56 @@ async def test_disabled_without_flag(self, monkeypatch): result = await yandex.fetch_from_yandex("9785961449136", 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") From ec138f204691b926fe500c27da12aff3af12390a Mon Sep 17 00:00:00 2001 From: Novikov Alexey <nov_dev@icloud.com> Date: Tue, 5 May 2026 07:11:54 +0300 Subject: [PATCH 7/9] =?UTF-8?q?feature(lookup):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20Playwright=20fallback=20=D0=BF=D0=BE?= =?UTF-8?q?=20MyBook=20=D0=B8=20Labirint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил новый Playwright fallback по книжным сайтам для ISBN, которые не удается стабильно добрать через каталоги и поисковики. Новый слой идет напрямую в LiveLib, MyBook и Labirint, пытается вытащить структурированные title, author и ссылку на карточку книги, а затем подключается в lookup-цепочку до DuckDuckGo. Это нужно как последний шанс для кейсов, где книга существует на книжных сайтах и находится человеком вручную, но обычные провайдеры не возвращают понятный результат. Для текущих проблемных ISBN живой прогон уже дает корректную карточку через MyBook или Labirint, а в тестах зафиксирован порядок fallback и разбор HTML этих сайтов. --- home_library/providers/lookup.py | 13 +- .../providers/playwright_site_search.py | 270 ++++++++++++++++++ tests/test_home_library_providers.py | 120 ++++++++ 3 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 home_library/providers/playwright_site_search.py diff --git a/home_library/providers/lookup.py b/home_library/providers/lookup.py index bc286f0..3b975c5 100644 --- a/home_library/providers/lookup.py +++ b/home_library/providers/lookup.py @@ -2,7 +2,7 @@ Координирует поиск по ISBN через несколько провайдеров с fallback-логикой: Labirint → Piter → Google Books → Yandex (опционально) параллельно, -затем DuckDuckGo. +затем Playwright-поиск по книжным сайтам и DuckDuckGo. """ import asyncio @@ -14,6 +14,9 @@ from home_library.providers.ddg import fetch_from_ddg from home_library.providers.google_books import fetch_from_google_books from home_library.providers.labirint import fetch_from_labirint +from home_library.providers.playwright_site_search import ( + fetch_from_playwright_site_search, +) from home_library.providers.piter import fetch_from_piter from home_library.providers.yandex import fetch_from_yandex @@ -68,8 +71,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/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index b726b7c..8a53097 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -17,6 +17,7 @@ google_books, labirint, lookup, + playwright_site_search, piter, title_page, yandex, @@ -818,6 +819,103 @@ async def test_disabled_without_flag(self, monkeypatch): 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( @@ -957,3 +1055,25 @@ 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" From b80233c27acf3dd5bccfa5f9d24c3de522351790 Mon Sep 17 00:00:00 2001 From: Novikov Alexey <nov_dev@icloud.com> Date: Sat, 9 May 2026 14:30:31 +0300 Subject: [PATCH 8/9] =?UTF-8?q?chore(repo):=20=D0=B8=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20.bot.pid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Файл .bot.pid — это рантайм-артефакт скрипта запуска бота (хранит PID процесса для останова). Он постоянно мелькает в git status и не должен попадать в индекс. --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From d4894ae5f18a002003eddb65c0263264fa550ef6 Mon Sep 17 00:00:00 2001 From: Novikov Alexey <nov_dev@icloud.com> Date: Sat, 9 May 2026 14:54:13 +0300 Subject: [PATCH 9/9] =?UTF-8?q?feature(lookup):=20=D0=BF=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D0=BA=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=20=D0=A0=D0=93=D0=91=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет провайдер home_library/providers/rsl_rkp.py, который ходит в каталог Российской государственной библиотеки (search.rsl.ru, бывшая РКП) через bootstrap-GET за CSRF-токеном и POST к /site/ajax-search?language=ru. Ответ — JSON с TotalHits и HTML-фрагментом карточек в поле content; парсер извлекает data-id и описание ГОСТ 7.1, превращая его в BookRecord. В реальной выборке из 10 ISBN ру-сегмента РГБ покрывает 7/10 — больше, чем любой одиночный коммерческий каталог. Поэтому в home_library/providers/lookup.py РГБ ставится первым в основной гонке (Лабиринт/Piter/Google Books/Yandex остаются на месте). Поведение управляется флагом RSL_RKP_ENABLED. По умолчанию провайдер включён и не требует Playwright/Chromium. RSL_RKP_ENABLED=0 мгновенно возвращает None без сетевых запросов. Реализация по TDD: парсер ГОСТ-описания, парсер content HTML, интеграция fetch_from_rsl_rkp через httpx.MockTransport (bootstrap CSRF, happy path, TotalHits=0, сетевой сбой, отсутствующий CSRF) и тест приоритета в lookup chain. --- AGENTS.md | 9 + MEMORY.md | 3 +- README.md | 19 +- home_library/config.py | 5 + home_library/providers/lookup.py | 10 +- home_library/providers/rsl_rkp.py | 296 ++++++++++++++++++++++++ tests/fixtures/rsl_search_found.json | 8 + tests/fixtures/rsl_search_notfound.json | 8 + tests/test_home_library_providers.py | 214 ++++++++++++++++- 9 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 home_library/providers/rsl_rkp.py create mode 100644 tests/fixtures/rsl_search_found.json create mode 100644 tests/fixtures/rsl_search_notfound.json diff --git a/AGENTS.md b/AGENTS.md index 92d96ee..0a2bd5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,15 @@ 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 5751e78..9ebcb20 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -22,7 +22,8 @@ - Бот сам создает `library.db` и `users.db`, если файлов нет. - `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`. - `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта. -- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> Yandex (опциональный) -> 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]=<isbn>`. Ответ — 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`. diff --git a/README.md b/README.md index 0264c53..2fffe2e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Home Library - Telegram-бот для учета домашней библиот - Пользователь может исправить любое поле вручную перед поиском. - Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год. - Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки. -- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. +- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: РГБ (search.rsl.ru — национальный депозитарий, основной для префикса `978-5-…`), Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo. - Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`. - Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки. - После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше). @@ -96,6 +96,23 @@ python -m home_library сработает интерактивная подсказка, бот попросит название/автора и перезапустит поиск через DuckDuckGo. +### Каталог РГБ (search.rsl.ru) + +Российская государственная библиотека (включая бывшую Российскую книжную +палату) — национальный депозитарий и хранит данные по большинству книг, +вышедших с префиксом `978-5-…`. Этот провайдер опрашивается **первым** в +основной гонке и обеспечивает наиболее полное покрытие ру-сегмента. + +Включён по умолчанию, не требует отдельных зависимостей. Чтобы выключить — +например, на нестабильной сети: + +```bash +export RSL_RKP_ENABLED=0 +``` + +С `RSL_RKP_ENABLED=0` провайдер мгновенно возвращает `None` и не делает +ни одного сетевого запроса. + ## Фото Оборота Титульного Листа Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`. 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/providers/lookup.py b/home_library/providers/lookup.py index 3b975c5..8e5c128 100644 --- a/home_library/providers/lookup.py +++ b/home_library/providers/lookup.py @@ -14,10 +14,11 @@ from home_library.providers.ddg import fetch_from_ddg from home_library.providers.google_books import fetch_from_google_books from home_library.providers.labirint import fetch_from_labirint +from home_library.providers.piter import fetch_from_piter from home_library.providers.playwright_site_search import ( fetch_from_playwright_site_search, ) -from home_library.providers.piter import fetch_from_piter +from home_library.providers.rsl_rkp import fetch_from_rsl_rkp from home_library.providers.yandex import fetch_from_yandex logger = logging.getLogger(__name__) @@ -46,9 +47,12 @@ async def fetch_book_by_isbn(isbn: str) -> BookRecord | None: logger.info("Начинаю поиск книги по ISBN %s", isbn) # Порядок приоритета: результат первого по индексу побеждает. - # Яндекс подключён опционально через env YANDEX_ENABLED=1 — без флага - # провайдер мгновенно возвращает None и не замедляет гонку. + # РГБ — национальный депозитарий, для префикса 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), 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/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/test_home_library_providers.py b/tests/test_home_library_providers.py index 8a53097..8db4920 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -2,6 +2,7 @@ import builtins as _builtins import io +import json import shutil from pathlib import Path @@ -17,8 +18,9 @@ google_books, labirint, lookup, - playwright_site_search, piter, + playwright_site_search, + rsl_rkp, title_page, yandex, ) @@ -1077,3 +1079,213 @@ async def mock_ddg(isbn, client): 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