From ee2d85482b8f5a323585d79f26e17a8cf5960657 Mon Sep 17 00:00:00 2001 From: Novikov Alexey Date: Thu, 23 Apr 2026 00:13:34 +0300 Subject: [PATCH] =?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 # ---------------------------------------------------------------------------