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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ users.db
*.log
stdout.txt
stderr.txt
.bot.pid
.coverage
coverage.xml
htmlcov/
Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ 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` и не замедляет гонку.

Каталог РГБ (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.
Expand Down
17 changes: 16 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
- Бот сам создает `library.db` и `users.db`, если файлов нет.
- `python init_db.py` всегда пересоздает только `library.db` из текущего `example_library.xlsx`.
- `LIBRARY_BOT_TOKEN` можно задавать через переменную окружения или через `.env` в корне проекта.
- Поиск книги по ISBN идет по цепочке `Labirint -> Piter -> Google Books -> DuckDuckGo`.
- Поиск книги по ISBN идет по цепочке `RSL/RKP (search.rsl.ru) -> Labirint -> Piter -> Google Books -> Yandex (опциональный) -> Playwright site search -> DuckDuckGo`. РГБ опрашивается первым: для префиксов `978-5-…` это даёт ~70% покрытия по реальной выборке без зависимости от Playwright/CAPTCHA.
- РГБ-провайдер включён по умолчанию; `RSL_RKP_ENABLED=0` мгновенно отключает его без сетевых вызовов. Для запроса нужен один bootstrap-`GET https://search.rsl.ru/` (отдаёт CSRF + cookies), затем `POST /site/ajax-search?language=ru` с `SearchFilterForm[search]=<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`.
- Общие паттерны чистки 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 All @@ -43,6 +49,15 @@
- Для тестов SQLite путь к БД подменяется через `monkeypatch` в `tests/conftest.py`.
- Для новых фич придерживаться test-first подхода: сначала тесты, потом реализация.

## Git Workflow Notes

- Большие наборы изменений сначала раскладывать по отдельным тематическим веткам, а не копить в одной длинной feature-ветке.
- По возможности держать одну пользовательскую или техническую задачу в одной ветке и в одном PR.
- Коммиты делать атомарными: один логический шаг, в идеале одно изменение в одном файле или в тесно связанном наборе файлов.
- Формат commit message: `feature(<area>): <короткое описание>`, `fix(<area>): <короткое описание>`, `refactor(<area>): <короткое описание>`.
- В body коммита и описании PR писать понятное человеку объяснение: что изменилось, какую проблему это решает и зачем выбран именно такой вариант.
- Если новая ветка зависит от другой тематической ветки, явно сохранять эту зависимость в базе ветки, а не прятать ее в конфликтах при переносе.

## Pending Architectural Work

- Хэндлеры разбиты на `commands.py`, `callbacks.py`, `access.py`, `_helpers.py` и `main.py`.
Expand Down
86 changes: 81 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ Home Library - Telegram-бот для учета домашней библиот
Обычный сценарий работы с ботом выглядит так:

- Вы фотографируете штрихкод на задней стороне книги.
- Бот распознает ISBN по фотографии.
- Затем он проверяет, есть ли эта книга уже в каталоге домашней библиотеки.
- Если книга уже есть, бот показывает ее карточку и дает перейти к редактированию.
- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: Лабиринт, Piter, Google Books и DuckDuckGo.
- Бот распознает ISBN по фотографии и сначала показывает черновик распознанных полей: `ISBN`, `Автор`, `Название`.
- Пользователь может исправить любое поле вручную перед поиском.
- Если ISBN или название не распознаны, можно прислать фото оборота титульного листа. На этой странице обычно есть выходные сведения: ISBN, автор, название, издательство и год.
- Затем бот проверяет, есть ли эта книга уже в каталоге домашней библиотеки.
- Если книги еще нет, бот ищет информацию о ней по ISBN во внешних источниках: РГБ (search.rsl.ru — национальный депозитарий, основной для префикса `978-5-…`), Лабиринт, Piter, Google Books, опциональный Яндекс (Playwright) и DuckDuckGo.
- Если поиск по одному ISBN ничего не дал, бот попросит короткую подсказку (название или автора) и попробует ещё раз через DuckDuckGo с контекстом — или можно отменить через `/cancel`.
- Когда данные найдены, бот предлагает добавить книгу в каталог домашней библиотеки.
- После добавления можно указать или изменить статус, оценку, комментарий, темы и местоположение книги (например: дома, на даче, у мамы, дал Саше).

Expand All @@ -21,6 +23,7 @@ Home Library - Telegram-бот для учета домашней библиот
- отправьте `/start`, чтобы увидеть краткую справку
- отправьте `/search <запрос>` или просто текстовое сообщение, чтобы найти книгу в каталоге
- отправьте фото штрихкода, если хотите найти и добавить книгу по ISBN
- при необходимости пришлите фото оборота титульного листа, чтобы бот добрал автора, название и ISBN по выходным сведениям
- используйте кнопки в боте, чтобы менять статус, оценку, комментарий, темы и местоположение
- если вы админ, используйте `/add_access_to_library` для выдачи Telegram-доступа членам семьи
- если вы админ, используйте `/export <json|xlsx|csv|yaml|db>` для выгрузки каталога
Expand All @@ -42,6 +45,16 @@ python -m pip install --upgrade pip
python -m pip install -r requirements.txt
```

Для OCR оборота титульного листа дополнительно нужен системный `tesseract`.

macOS:

```bash
brew install tesseract tesseract-lang
```

Проверьте, что доступны языки `rus` и `eng`. Без `tesseract` бот всё равно будет работать, но OCR для оборота титульного листа будет недоступен.

Создайте файл `.env` в корне проекта и запишите в него следующее:

```dotenv
Expand All @@ -59,6 +72,69 @@ python -m home_library

Если `library.db` еще нет, приложение само создаст ее из `example_library.xlsx`.

## Опционально: Яндекс через Playwright

Ряд ISBN (например, `9785961449136`) не находится ни в одном книжном API/HTML —
только в поиске Яндекса. Обычный HTTP к `ya.ru` ловит SmartCaptcha, поэтому
опциональный провайдер ходит через headless Chromium с `playwright-stealth`.

1. Установите dev-зависимости:
```bash
python -m pip install -r requirements-dev.txt
```
2. Скачайте Chromium (≈170MB):
```bash
playwright install chromium
```
3. Включите провайдер флагом:
```bash
export YANDEX_ENABLED=1
```

Без флага провайдер тихо возвращает `None` и бот работает как раньше. Первый
запрос в сессии ≈3–5 с (старт Chromium). Если Яндекс всё же показал капчу —
сработает интерактивная подсказка, бот попросит название/автора и перезапустит
поиск через DuckDuckGo.

### Каталог РГБ (search.rsl.ru)

Российская государственная библиотека (включая бывшую Российскую книжную
палату) — национальный депозитарий и хранит данные по большинству книг,
вышедших с префиксом `978-5-…`. Этот провайдер опрашивается **первым** в
основной гонке и обеспечивает наиболее полное покрытие ру-сегмента.

Включён по умолчанию, не требует отдельных зависимостей. Чтобы выключить —
например, на нестабильной сети:

```bash
export RSL_RKP_ENABLED=0
```

С `RSL_RKP_ENABLED=0` провайдер мгновенно возвращает `None` и не делает
ни одного сетевого запроса.

## Фото Оборота Титульного Листа

Если после фото штрихкода данные распознаны не полностью, бот предложит кнопку `📖 Пришлю оборот титульного листа`.

Подходящее фото обычно содержит выходные сведения, например:

```text
Сью Алекс
System Design. Подготовка к сложному интервью.
СПб.: Питер, 2022
ISBN 978-5-4461-1816-8
ББК 32.973.2-02
УДК 004.41
```

Советы для OCR:

- фотографируйте страницу целиком
- держите камеру ровно над страницей
- избегайте сильных теней и бликов
- лучше присылать именно оборот титульного листа, а не случайную первую страницу текста

## Первый Запуск

- `LIBRARY_BOT_TOKEN` обязателен всегда. Получить токен можно у [@BotFather](https://t.me/BotFather).
Expand Down Expand Up @@ -166,7 +242,7 @@ python -m home_library
## Важно

- `/search` и обычное текстовое сообщение ищут только по уже сохраненным книгам в каталоге домашней библиотеки
- внешний поиск используется только в сценарии добавления книги по фото штрихкода
- внешний поиск используется только в сценарии добавления книги по фото штрихкода или после подтверждения OCR-черновика
- экспорт `/export db` отдает только `library.db`; `users.db` в экспорт не входит

## Файлы Данных
Expand Down
5 changes: 5 additions & 0 deletions home_library/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions home_library/interfaces/telegram/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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}"
2 changes: 2 additions & 0 deletions home_library/interfaces/telegram/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
handle_export_callback,
handle_photo,
handle_remove_access_callback,
handle_scan_draft_callback,
handle_set_callback,
)

Expand Down Expand Up @@ -86,6 +87,7 @@
"handle_export_callback",
"handle_photo",
"handle_remove_access_callback",
"handle_scan_draft_callback",
"handle_set_callback",
"log_unhandled_error",
"main",
Expand Down
37 changes: 37 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,41 @@

logger = logging.getLogger(__name__)

PENDING_ISBN_HINT_KEY = "pending_isbn_hint"
PENDING_SCAN_DRAFT_KEY = "pending_scan_draft"
SCAN_DRAFT_EDITING_KEY = "scan_draft_editing"
PENDING_BOOK_EDITING_KEY = "pending_book_editing"


@dataclass(frozen=True)
class PendingIsbnHint:
"""Состояние ожидания подсказки от пользователя по не найденному ISBN."""

isbn: str
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))


@dataclass(slots=True)
class PendingScanDraft:
"""Черновик данных книги, распознанных с фото до запуска поиска."""

token: str
isbn: str = ""
author: str = ""
title: str = ""
awaiting_verso_photo: bool = False
recognized_from_verso: bool = False
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))


@dataclass(slots=True)
class PendingBookEdit:
"""Состояние ручной правки книги перед добавлением в каталог."""

field: str
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))


EDIT_CALLBACK_MIN_PARTS = 3
OPTIONAL_USER_ID_INDEX = 3
SET_CALLBACK_MIN_PARTS = 5
Expand Down
Loading
Loading