Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export LIBRARY_BOT_TOKEN="<token>"
export LIBRARY_BOT_ADMIN_USERNAME="<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.
Expand Down
5 changes: 4 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<username>` или `LIBRARY_BOT_ADMIN_TELEGRAM_USER_ID=<числовой id>`; первым в бота должен написать именно этот Telegram-аккаунт, после чего его `telegram_user_id` фиксируется в `users.db` как админский.
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Home Library - Telegram-бот для учета домашней библиот
- Бот распознает ISBN по фотографии.
- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки.
- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию.
- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books и DuckDuckGo.
- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo.
- Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`.
- Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки.
- После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше).

Expand Down Expand Up @@ -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).
Expand Down
13 changes: 13 additions & 0 deletions home_library/interfaces/telegram/handlers/_helpers.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
58 changes: 57 additions & 1 deletion home_library/interfaces/telegram/handlers/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import hashlib
import logging

import httpx
from telegram import (
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
Update,
)
from telegram.ext import ContextTypes
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions home_library/interfaces/telegram/handlers/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending ISBN hint until input is successfully handled

pending_isbn_hint is removed before validating/processing the hint, so an empty message or a transient lookup failure drops the flow immediately. In these cases the bot tells the user to try again, but the next message is treated as a normal search because the pending state is already gone. This makes the new hint flow brittle and user-visible retries impossible without rescanning the barcode.

Useful? React with 👍 / 👎.

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:
"""Обрабатывает текстовый ввод при редактировании поля.

Expand All @@ -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()
Expand Down
Loading