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
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
16 changes: 15 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
- Бот сам создает `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`. Каждой найденной карточке `lookup` проставляет `BookRecord.confidence` через `home_library/providers/_score.score_book_record`: верифицированные каталоги (РГБ, Labirint, Piter, MIF) — высокий вес, SERP-fallback (DDG, Yandex, Playwright site search) — средний/низкий. Если у возвращённой карточки `confidence < CONFIDENCE_THRESHOLD` (по умолчанию 0.6 из `home_library/config.py`), `_present_book_for_isbn` не показывает preview, а ставит `PendingScanDraft` с предзаполненными полями и просьбой проверить вручную.
- Фото-кейс теперь двухшаговый: после первого фото бот не ищет сразу, а показывает `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 +48,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
69 changes: 64 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 во внешних источниках: Лабиринт, 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,52 @@ 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.

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

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

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

```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 +225,7 @@ python -m home_library
## Важно

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

## Файлы Данных
Expand Down
1 change: 1 addition & 0 deletions home_library/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
SEARCH_LIMIT = 15
ISBN13_LENGTH = 13
ISBN10_LENGTH = 10
CONFIDENCE_THRESHOLD = 0.6


@dataclass(frozen=True)
Expand Down
1 change: 1 addition & 0 deletions home_library/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class BookRecord(SqliteRowModel):
isbn: str = ""
id: int | None = None
user_data: list[UserBookDataRecord] = Field(default_factory=list)
confidence: float = Field(default=1.0, ge=0.0, le=1.0)


@dataclass(slots=True)
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