Compare commits

..

6 Commits

Author SHA1 Message Date
bc7b5bfe37 upd 2026-05-01 03:50:25 +03:00
43597be020 upd 2026-05-01 03:27:33 +03:00
469fd1ba94 upd 2026-05-01 02:45:09 +03:00
9d5d840898 upd 2026-05-01 02:02:36 +03:00
b4e4a51ae5 upd 2026-04-30 19:32:13 +03:00
87b692ba49 upd 2026-04-30 18:54:24 +03:00
20 changed files with 3338 additions and 1369 deletions

View File

@@ -7,6 +7,7 @@
3. [Стек технологий](#3-стек-технологий) 3. [Стек технологий](#3-стек-технологий)
4. [Схема архитектуры](#4-схема-архитектуры) 4. [Схема архитектуры](#4-схема-архитектуры)
5. [Модули бэкенда](#5-модули-бэкенда) 5. [Модули бэкенда](#5-модули-бэкенда)
- [auth.py](#authpy)
- [browser.py](#browserpy) - [browser.py](#browserpy)
- [scraper.py](#scraperpy) - [scraper.py](#scraperpy)
- [exporter.py](#exporterpy) - [exporter.py](#exporterpy)
@@ -29,7 +30,7 @@
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы: Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. - **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. Требует входа — поддерживает многопользовательский режим с ролями `admin` / `user`.
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`. - **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
--- ---
@@ -41,6 +42,7 @@ manga/
├── src/ # Весь бэкенд-код (Python-пакет) ├── src/ # Весь бэкенд-код (Python-пакет)
│ ├── __init__.py │ ├── __init__.py
│ ├── api.py # FastAPI-приложение, REST + WebSocket │ ├── api.py # FastAPI-приложение, REST + WebSocket
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
│ ├── browser.py # Обёртка над Playwright/Chromium │ ├── browser.py # Обёртка над Playwright/Chromium
│ ├── cli.py # CLI-команды (click) │ ├── cli.py # CLI-команды (click)
│ ├── downloader.py # (legacy, не используется в web-режиме) │ ├── downloader.py # (legacy, не используется в web-режиме)
@@ -100,6 +102,7 @@ manga/
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ FastAPI (api.py) │ │ FastAPI (api.py) │
│ │ │ │
│ Auth middleware (cookie/session)│
│ REST endpoints WebSocket /ws │ │ REST endpoints WebSocket /ws │
│ │ │ │ │ │ │ │
│ asyncio.Queue ws_manager │ │ asyncio.Queue ws_manager │
@@ -129,6 +132,25 @@ manga/
## 5. Модули бэкенда ## 5. Модули бэкенда
### auth.py
**Отвечает за:** хеширование паролей и генерацию токенов сессий.
**Константы:**
```python
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
```
**Функции:**
- `hash_password(password)``str` — хеширует пароль методом PBKDF2-SHA256, 260 000 итераций, случайная соль. Формат результата: `pbkdf2:260000:<salt_hex>:<key_hex>`.
- `verify_password(password, hashed)``bool` — проверяет пароль по хешу через `hmac.compare_digest` (защита от timing-атак).
- `generate_session_token()``str` — генерирует 48-байтный URL-safe токен (`secrets.token_urlsafe`).
---
### browser.py ### browser.py
**Отвечает за:** запуск и управление Playwright Chromium. **Отвечает за:** запуск и управление Playwright Chromium.
@@ -248,9 +270,11 @@ class MangaInfo:
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` | | `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` | | `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
| `chapters_total` | INTEGER | Кол-во глав (из scraper) | | `chapters_total` | INTEGER | Кол-во глав (из scraper) |
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ | | `chapters_done` | INTEGER | Денормализованный счётчик¹ |
| `last_checked_at` | TEXT | Время последней проверки новых глав | | `last_checked_at` | TEXT | Время последней проверки новых глав |
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) | | `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
| `source_id` | INTEGER | FK → sources.id — источник манги |
| `added_by` | INTEGER | FK → users.id — кто добавил мангу |
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`. ¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
@@ -275,15 +299,59 @@ class MangaInfo:
| `new_chapter_found` | Найдена новая глава при проверке | | `new_chapter_found` | Найдена новая глава при проверке |
| `check_started` / `check_done` | Начало/конец проверки обновлений | | `check_started` / `check_done` | Начало/конец проверки обновлений |
**`sources`** — источники (определяются в коде приложения):
| Колонка | Тип | Описание |
|---------|-----|---------|
| `slug` | TEXT UNIQUE | Идентификатор источника (`readmanga`) |
| `display_name` | TEXT | Отображаемое название |
| `settings` | TEXT | JSON-настройки источника |
**`source_domains`** — домены, привязанные к источникам:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `source_id` | INTEGER | FK → sources.id |
| `domain` | TEXT UNIQUE | Домен без схемы и www (`readmanga.ru`) |
При первом запуске домены ReadManga автоматически засеиваются из списка `_DEFAULT_READMANGA_DOMAINS` в коде.
**`users`** — пользователи:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `id` | INTEGER PK | |
| `username` | TEXT UNIQUE | Логин |
| `password` | TEXT | Хеш пароля (pbkdf2:iterations:salt:key) |
| `role` | TEXT | `admin` / `user` |
| `is_env_admin` | INTEGER | 1 — системный администратор из `AUTH_LOGIN`/`AUTH_PASSWORD`; его пароль нельзя изменить через интерфейс |
| `created_at` / `updated_at` | TEXT | ISO-8601 timestamp |
**`sessions`** — активные сессии:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `token` | TEXT PK | URL-safe токен (48 байт) |
| `user_id` | INTEGER | FK → users.id ON DELETE CASCADE |
| `created_at` / `expires_at` | TEXT | Сессия действительна 30 дней |
#### Ключевые методы #### Ключевые методы
- `add_manga(url, fmt)``bool` — добавляет, возвращает `False` если уже есть. - `add_manga(url, fmt, source_id, added_by)``bool` — добавляет, возвращает `False` если уже есть.
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`). - `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
- `chapter_status(chapter_url)``str | None`. - `chapter_status(chapter_url)``str | None`.
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число. - `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters.
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`. - `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку. - `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки. - `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
- `create_user(username, hashed_password, role, is_env_admin)``dict` — создаёт пользователя.
- `get_user_by_id(id)` / `get_user_by_username(username)``dict | None`.
- `get_all_users()``list[dict]` — все пользователи без поля `password`.
- `update_user(user_id, **kwargs)` — обновляет разрешённые поля (`username`, `password`, `role`).
- `delete_user(user_id)` — удаляет пользователя и все его сессии.
- `create_session(token, user_id, expires_at)` — создаёт сессию.
- `get_session(token)``dict | None` — возвращает только не истёкшие сессии.
- `cleanup_expired_sessions()``int` — удаляет истёкшие сессии.
--- ---
@@ -341,7 +409,17 @@ async with sem: # ограничение параллельности
### api.py ### api.py
**Отвечает за:** FastAPI-приложение — HTTP-сервер, очередь загрузок, планировщик обновлений. **Отвечает за:** FastAPI-приложение — HTTP-сервер, аутентификация, очередь загрузок, планировщик обновлений.
#### Auth-зависимости (Depends)
```python
get_current_user(request) # читает токен из cookie, валидирует сессию → dict пользователя
require_admin(user) # get_current_user + проверка role == "admin"
_check_manga_access(manga, user) # admin: полный доступ; user: только свои манги
```
Все endpoint'ы (кроме `/api/login` и `/api/auth/check`) требуют валидной сессии.
#### Глобальное состояние #### Глобальное состояние
@@ -354,14 +432,17 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
#### Вспомогательные функции #### Вспомогательные функции
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов). - `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`. Используется везде: `_enrich_manga`, `_manga_detail`, `delete_manga`, `rename_folder`. - `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions` с актуальным словарём `{url: позиция}`. Вызывается после любого изменения очереди: старт/конец задачи в воркере, `prioritize`, `stop`, `resume`, `add_to_queue`, `force_redownload`. - `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
#### Жизненный цикл при старте (`startup_event`) #### Жизненный цикл при старте (`startup_event`)
1. Запускает `queue_worker()` как фоновую Task. 1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
2. Запускает `update_scheduler()` как фоновую Task. 2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь). 3. Удаляет истёкшие сессии (`cleanup_expired_sessions`).
4. **Bootstrap-admin:** если таблица `users` пуста — создаёт пользователя из `AUTH_LOGIN`/`AUTH_PASSWORD` с ролью `admin` и флагом `is_env_admin=True`.
5. Запускает `queue_worker()` и `update_scheduler()` как фоновые Task.
6. Восстанавливает незавершённые задачи из БД (status `queued`/`downloading` → снова в очередь).
#### `queue_worker()` #### `queue_worker()`
@@ -373,18 +454,15 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается. - Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него. - Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**. - Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
#### `_enrich_manga(m, db)` #### `_enrich_manga(m, db)`
Вспомогательная функция: обогащает строку из `mangas` реальными данными: Вспомогательная функция: обогащает строку из `mangas` реальными данными:
- `chapters_done``COUNT(*)` из таблицы `chapters` (не стальная колонка). - `chapters_done``COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
- `size_bytes` / `size_human` — размер папки на диске. - `size_bytes` / `size_human` — размер папки на диске.
- `is_active` — есть ли Task в `active_tasks`. - `is_active` — есть ли Task в `active_tasks`.
- `errors_count` — сумма failed и partial глав. - `errors_count` — сумма failed и partial глав.
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
--- ---
### cli.py ### cli.py
@@ -429,6 +507,36 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
Базовый URL: `http://localhost:8000` Базовый URL: `http://localhost:8000`
Все endpoint'ы, кроме `/api/login` и `/api/auth/check`, требуют валидной сессии (cookie `manga_session`). Endpoint'ы с пометкой `[admin]` доступны только пользователям с ролью `admin`.
### Аутентификация
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/auth/check` | Проверить текущую сессию. Возвращает `{authenticated, user: {id, username, role, is_env_admin}}` |
| `POST` | `/api/login` | Войти `{login, password}`. Устанавливает cookie сессии на 30 дней |
| `POST` | `/api/logout` | Выйти. Удаляет сессию из БД и cookie |
### Управление пользователями `[admin]`
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/users` | Список всех пользователей (без паролей) |
| `POST` | `/api/users` | Создать пользователя `{username, password, role}` |
| `PATCH` | `/api/users/{user_id}` | Изменить `{username?, password?, role?}`. Для `is_env_admin=1` смена пароля заблокирована. Обычный пользователь может менять только свой пароль |
| `DELETE` | `/api/users/{user_id}` | Удалить пользователя. Системного администратора и последнего admin удалить нельзя |
### Источники
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/sources` | Список источников с доменами и настройками |
| `GET` | `/api/resolve-source?url=` | Определить источник по URL манги |
| `POST` | `/api/sources/{id}/domains` `[admin]` | Добавить домен к источнику `{domain}` |
| `DELETE` | `/api/sources/{id}/domains/{domain}` `[admin]` | Удалить домен у источника |
### Манги
| Метод | Путь | Описание | | Метод | Путь | Описание |
|-------|------|---------| |-------|------|---------|
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками | | `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
@@ -436,15 +544,21 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` | | `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку | | `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
| `POST` | `/api/mangas/resume?url=` | Возобновить | | `POST` | `/api/mangas/resume?url=` | Возобновить |
| `POST` | `/api/mangas/prioritize?url=` | Переместить в начало очереди (вытесняет текущую) | | `POST` | `/api/mangas/prioritize?url=` `[admin]` | Переместить в начало очереди (вытесняет текущую) |
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending | | `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление | | `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы | | `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` | | `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске и обновить пути в БД `{url, folder_name}` | | `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске `{url, folder_name}` |
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах | | `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
| `POST` | `/api/mangas/force_redownload?url=` | Сбросить все главы и поставить в очередь заново | | `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД | | `POST` | `/api/mangas/switch-source` `[admin]` | Сменить источник манги `{manga_url, source_id}` |
| `DELETE` | `/api/mangas?url=` `[admin]` | Удалить мангу из БД |
### Прочее
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) | | `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
| `GET` | `/api/history?limit=&manga_url=` | История событий | | `GET` | `/api/history?limit=&manga_url=` | История событий |
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` | | `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
@@ -469,7 +583,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления | | `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем | | `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована | | `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — отправляется при любом изменении очереди | | `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — при любом изменении очереди |
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы | | `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова | | `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) | | `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
@@ -479,6 +593,8 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава | | `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления | | `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены | | `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
| `source_domain_added` | `{source_id, domain}` | Добавлен домен к источнику |
| `source_domain_removed` | `{source_id, domain}` | Домен удалён у источника |
### Клиент → Сервер ### Клиент → Сервер
@@ -490,16 +606,24 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
## 9. Фронтенд ## 9. Фронтенд
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк). **Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика). **Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
### Экран входа
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
### Архитектура состояния ### Архитектура состояния
```javascript ```javascript
const state = { const state = {
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления) mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
chapters: {}, // url → массив глав (загружается по запросу в модалке) chapters: {}, // url → массив глав (загружается по запросу в модалке)
currentUser: null, // {id, username, role, is_env_admin}
sources: [], // список источников
}; };
``` ```
@@ -508,24 +632,41 @@ const state = {
``` ```
DOMContentLoaded DOMContentLoaded
├─ loadStats() ──────────────────────► GET /api/stats ├─ checkAuth() ──────────────────────► GET /api/auth/check
│ │ (если ok → initApp())
│ └─ showLoginScreen()
connectWS() ──────────────────────► WS /ws initApp()
├─ loadStats() ──────────────────► GET /api/stats
├─ connectWS() ──────────────────► WS /ws
│ │ │ │
│ └─ snapshot event ──────────► state.mangas = enriched list │ └─ snapshot event ───────► state.mangas = enriched list
│ + live events ──────────► state.mangas[url].* обновляется │ + live events ───────► state.mangas[url].* обновляется
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список └─ fetch('/api/mangas') ──────────► state.mangas = полный список
(перезаписывает snapshot если пришёл раньше)
``` ```
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`. **Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
### Вкладки ### Вкладки
- **Манга** — список всех манг, добавление, управление. - **Манга** — список всех манг, добавление, управление.
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось). - **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
- **История** — все события из таблицы `history`. - **История** — все события из таблицы `history`.
- **Настройки** — управление источниками, пользователями (только admin), смена своего пароля.
### Вкладка «Настройки»
При открытии загружает:
- Список источников (`GET /api/sources`) с управлением доменами (только admin).
- Список пользователей (`GET /api/users`, только admin) — в разделе **Пользователи**.
- Раздел **Сменить пароль** — скрыт для системного администратора (`is_env_admin=true`).
**Управление пользователями (только admin):**
- Создание: кнопка «+ Добавить» → модалка с логином, паролем, ролью.
- Редактирование: кнопка ✏️ → модалка. Для системного администратора (`is_env_admin`) поле пароля скрыто.
- Удаление: кнопка ✕ (недоступна для системного администратора и для самого себя).
- Системный администратор помечен иконкой 🔒 с тултипом.
### Модальное окно детали ### Модальное окно детали
@@ -533,7 +674,7 @@ DOMContentLoaded
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`. - **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`. - **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`). - **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
- **↺ Скачать заново** — сбрасывает все главы и ставит в очередь повторно. - **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
### Карточки манги (кнопки) ### Карточки манги (кнопки)
@@ -544,7 +685,7 @@ DOMContentLoaded
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку | | ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
| ▶ | `status` = `stopped` или `failed` | Возобновить | | ▶ | `status` = `stopped` или `failed` | Возобновить |
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди | | 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
| ✕ | всегда | Удалить | | ✕ `[admin]` | всегда | Удалить |
### Позиции в очереди ### Позиции в очереди
@@ -558,10 +699,11 @@ DOMContentLoaded
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно | | `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён | | `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически | | `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация |
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) | | `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
### Пути (hardcoded в коде) ### Пути (hardcoded в коде)
@@ -602,7 +744,7 @@ ports:
shm_size: "2gb" # Chromium требует shared memory shm_size: "2gb" # Chromium требует shared memory
environment: environment:
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron) - UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
- AUTH_LOGIN=... - AUTH_LOGIN=... # системный администратор
- AUTH_PASSWORD=... - AUTH_PASSWORD=...
restart: unless-stopped # Автоперезапуск при падении restart: unless-stopped # Автоперезапуск при падении
@@ -624,7 +766,7 @@ docker compose run --rm --entrypoint "" manga \
После остановки контейнера все данные сохраняются на хосте: После остановки контейнера все данные сохраняются на хосте:
- `./output/` — скачанные файлы. - `./output/` — скачанные файлы.
- `./state/progress.db` — состояние БД (что скачано, что в очереди). - `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
- `./state/manga.log` — логи. - `./state/manga.log` — логи.
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь. При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.

369
CODE_REVIEW.md Normal file
View File

@@ -0,0 +1,369 @@
# Code Review: находки и предложения
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
---
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
**Файлы:** `src/api.py:251`, `src/worker.py:2632`, `src/cli.py` (аналогичные функции)
Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения.
**Исправление:** вынести в `src/utils.py`, импортировать везде:
```python
# src/utils.py
import re
from .sources.base import Chapter
def safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def safe_chapter_name(ch: Chapter) -> str:
vol = f"v{ch.volume:02d}_" if ch.volume else ""
return f"{vol}ch{ch.number:06.1f}"
```
---
## 2. Прямой доступ к `db.conn` в API-эндпоинтах
**Файлы:** `src/api.py`
| Место | Строки |
|-------|--------|
| `_enrich_manga` | 269278 |
| `retry_errors` | 680688 |
| `force_redownload` | 819823 |
| `delete_manga` | 882885 |
| `rename_folder` | 801803 |
`api.py` напрямую исполняет SQL через `db.conn.execute(...)` вместо методов `StateDB`. Это означает, что логика разбросана между двумя слоями, и рефакторинг `StateDB` может незаметно сломать эндпоинты.
Для `retry_errors` и `force_redownload` в `StateDB` уже есть готовый метод `reset_failed_chapters` — он просто не используется в API.
**Исправление для `retry_errors`:**
```python
# api.py — было:
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
db.conn.execute("UPDATE chapters SET status='pending' ...", (now, url))
db.conn.commit()
# стало:
db.reset_failed_chapters(url)
```
Для `delete_manga` и `rename_folder` добавить соответствующие методы в `StateDB`.
---
## 3. `datetime.utcnow()` устарел
**Файлы:** `src/api.py:369`, `src/state.py:628`
`datetime.utcnow()` объявлен deprecated в Python 3.12. Используется в двух местах.
**Исправление:**
```python
# src/state.py
from datetime import datetime, timezone
def _now() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
# src/api.py — в login():
expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None).isoformat()
```
---
## 4. `check_for_updates` не использует `db_lock`
**Файл:** `src/worker.py:343400`
`download_manga` тщательно оборачивает все обращения к БД в `db_lock` (`asyncio.Lock`). `check_for_updates` вызывает `db.update_manga_info`, `db.get_all_chapters`, `db.set_last_checked` напрямую без блокировки. При параллельном запуске нескольких авто-обновлений возможна конкуренция записей в одну и ту же строку SQLite.
**Исправление:** добавить `db_lock` в `check_for_updates` по аналогии с `download_manga`, либо убедиться, что авто-обновления всегда запускаются последовательно (сейчас в `_run_auto_updates` они идут через `for manga in candidates` — это последовательно, но ручной `check_now` и авто-обновление могут пересечься).
---
## 5. Хак `pages_done_count = [0]`
**Файл:** `src/worker.py:196`
```python
pages_done_count = [0] # мутабельный список вместо nonlocal
async def on_page(page_idx: int, pages_total: int):
pages_done_count[0] += 1
```
Это классический обходной путь для `nonlocal` в Python 2. В Python 3 достаточно `nonlocal`.
**Исправление:**
```python
pages_done = 0
async def on_page(page_idx: int, pages_total: int):
nonlocal pages_done
pages_done += 1
await db_call(db.update_chapter_pages, ch.url, pages_total, pages_done)
```
---
## 6. Мёртвый код в `StateDB`
**Файл:** `src/state.py:405407`
```python
def increment_manga_chapters_done(self, url: str):
# Оставлен для совместимости, но не используется в воркере
pass
```
Метод ничего не делает и нигде не вызывается.
**Исправление:** удалить.
---
## 7. Отложенный импорт `BrowserManager` в `_fetch_preview`
**Файл:** `src/api.py:548`
```python
async def _fetch_preview(url: str):
try:
from .browser import BrowserManager # импорт внутри функции
```
`BrowserManager` уже импортируется в `worker.py`. Импорт внутри функции — признак того, что его пытались скрыть из-за каких-то проблем при старте, но сейчас оснований для этого нет. Это замедляет первый вызов и затрудняет поиск зависимостей.
**Исправление:** добавить `from .browser import BrowserManager` в топ-уровневые импорты `api.py`.
Аналогично — `import shutil` внутри тел `rename_folder` (строка 789) и `delete_manga` (строка 879). Вынести в топ.
---
## 8. O(n²) назначение позиций в очереди
**Файл:** `src/api.py:486491`
```python
queue_list = list(download_queue._queue)
for i, job in enumerate(queue_list):
for r in result: # ← внутренний цикл по всем мангам
if r["url"] == job["url"]:
r["queue_position"] = i + 1
```
При 100 мангах в очереди и 100 в БД — 10 000 итераций на каждый запрос `/api/mangas`.
**Исправление:**
```python
queue_positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
for r in result:
r["queue_position"] = queue_positions.get(r["url"])
```
---
## 9. Утечка памяти в `_export_pdf_pillow`
**Файл:** `src/exporter.py:131135`
```python
def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images]
if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
# pil_images не закрываются — файловые дескрипторы висят до GC
```
**Исправление:**
```python
def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images]
try:
if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
finally:
for img in pil_images:
img.close()
```
---
## 10. Потенциальный SQL-инъекционный путь в `mark_done`
**Файл:** `src/state.py:453459`
```python
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
col = f"output_{fmt}"
self.conn.execute(f"""
UPDATE chapters SET status='done', {col}=?, updated_at=?
WHERE chapter_url=?
""", (output_path, _now(), chapter_url))
```
Сейчас `fmt` всегда приходит из `["cbz", "pdf", "epub"]` в `worker.py`, поэтому эксплуатации нет. Но паттерн хрупкий: если завтра `fmt` придёт от пользователя через API, это станет инъекцией.
**Исправление:**
```python
_ALLOWED_FMTS = {"cbz", "pdf", "epub"}
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
if fmt not in _ALLOWED_FMTS:
raise ValueError(f"Unknown format: {fmt}")
col = f"output_{fmt}"
...
```
---
## 11. Неиспользуемый метод `BrowserManager.navigate()`
**Файл:** `src/browser.py`
`BrowserManager` содержит метод `navigate()`, который нигде не вызывается — `readmanga.py` определяет собственный `_navigate`. Это мёртвый код.
**Исправление:** удалить `navigate()` из `BrowserManager` или убрать `_navigate` из `readmanga.py` и использовать единый метод.
---
## 12. `cli.py` использует устаревший шим вместо реестра источников
**Файл:** `src/cli.py`
```python
from .scraper import get_manga_info, get_chapter_images_and_download # shim
```
`scraper.py` — это обёртка-пустышка, делегирующая в `ReadmangaSource`. CLI не использует `SourceRegistry`, не поддерживает `MangaMeta` полноценно. При добавлении нового источника CLI не заработает автоматически.
**Исправление:** переписать `cli.py` на использование `registry` и `get_source_for_url`, как это сделано в `worker.py`.
---
## 13. Двойное чтение тела ответа в `saveRenameFolder`
**Файл:** `frontend/index.html`
```javascript
async function saveRenameFolder() {
const r = await fetch('/api/mangas/rename_folder', ...);
if (!r.ok) {
const err = await r.json(); // ← первое чтение
...
}
const data = await r.json(); // ← второе чтение (тело уже прочитано!)
```
`Response.body` — это `ReadableStream`, читается один раз. Второй `r.json()` вернёт ошибку или пустой объект.
**Исправление:**
```javascript
const data = await r.json();
if (!r.ok) {
showError(data.detail || 'Ошибка');
return;
}
```
---
## 14. `escHtml()` не защищает от JS-инъекции в `onclick`
**Файл:** `frontend/index.html` — различные места типа:
```javascript
`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">
```
`escHtml` экранирует HTML-спецсимволы (`<`, `>`, `&`), но не защищает от инъекции в контекст JavaScript-строки. Если `u.username` содержит `'); evil() //` — экранирование не поможет.
**Исправление:** вместо `onclick="..."` в строке шаблона использовать `data-*` атрибуты + делегирование событий:
```javascript
`<button class="edit-user-btn" data-id="${u.id}" data-username="${escAttr(u.username)}">`
// один раз:
document.addEventListener('click', e => {
const btn = e.target.closest('.edit-user-btn');
if (btn) openEditUserModal(+btn.dataset.id, btn.dataset.username, ...);
});
```
---
## 15. Дублирование `forceRedownload` / `forceRedownloadModal`
**Файл:** `frontend/index.html`
Две функции с практически идентичным телом — `forceRedownload(url)` и `forceRedownloadModal(url)`. Отличие только в том, что вторая закрывает модал перед вызовом.
**Исправление:** одна функция с необязательным флагом или вызов `closeModal()` перед `forceRedownload()` там, где нужно.
---
## 16. `check_for_updates` в `worker.py` импортирует `scraper` шим
**Файл:** `src/worker.py:16`
```python
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
```
Эти символы нигде в файле не используются — `worker.py` работает через `registry`. Мёртвый импорт.
**Исправление:** удалить строку.
---
## 17. Одиночный SQLite-коннект с `check_same_thread=False` в async-среде
**Файл:** `src/state.py:27`
```python
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
```
Каждый `StateDB()` создаёт новый коннект, но внутри одного воркера `db` живёт на всё время скачивания манги. `check_same_thread=False` отключает встроенную защиту SQLite от использования коннекта в нескольких потоках. В текущей архитектуре с `asyncio` и `db_lock` это безопасно, но хрупко — одно забытое `await` без блокировки может привести к `database is locked` или повреждению данных.
**Рекомендация:** ничего не менять прямо сейчас, но при следующем крупном рефакторинге рассмотреть `aiosqlite` или SQLite WAL-режим (`PRAGMA journal_mode=WAL`) для снижения вероятности блокировок при параллельных читателях.
---
## Сводная таблица приоритетов
| # | Файл | Проблема | Приоритет |
|---|------|----------|-----------|
| 2 | api.py | Прямой `db.conn` в эндпоинтах (retry, force, delete) | Высокий |
| 8 | api.py | O(n²) очередь позиций | Высокий |
| 13 | frontend | Двойное чтение `r.json()`баг | Высокий |
| 4 | worker.py | `check_for_updates` без `db_lock` | Средний |
| 9 | exporter.py | Утечка PIL-объектов в PDF Pillow | Средний |
| 10 | state.py | Хрупкий f-string в `mark_done` | Средний |
| 3 | api/state | `datetime.utcnow()` deprecated | Низкий |
| 1 | api/worker/cli | Дублирование `_safe_name` | Низкий |
| 5 | worker.py | Хак `pages_done_count = [0]` | Низкий |
| 6 | state.py | Мёртвый метод `increment_manga_chapters_done` | Низкий |
| 7 | api.py | Поздний `import` внутри функций | Низкий |
| 11 | browser.py | Мёртвый метод `navigate()` | Низкий |
| 12 | cli.py | Устаревший шим, нет поддержки реестра | Низкий |
| 14 | frontend | JS-инъекция через `onclick` + `escHtml` | Низкий (internal tool) |
| 15 | frontend | Дублирование `forceRedownload*` | Низкий |
| 16 | worker.py | Мёртвый импорт scraper shim | Низкий |
| 17 | state.py | `check_same_thread=False` в async | На будущее |

352
PLAN_MULTI_SOURCE.md Normal file
View File

@@ -0,0 +1,352 @@
# План реализации: Multi-Source архитектура
Рефакторинг под систему плагинов-адаптеров: каждый источник — отдельный класс с унифицированным `Protocol`-интерфейсом. Новые таблицы `sources` / `source_domains` в БД, автоопределение источника по домену URL, CRUD-API для доменов и UI-компоненты во фронтенде. Существующий `scraper.py` становится адаптером `ReadmangaSource`.
---
## 1. Архитектура системы источников
**Организация**: `Protocol`-интерфейс + реестр (`SourceRegistry`) + slug-имена в коде.
Создать `src/sources/` — пакет с адаптерами:
```
src/sources/
__init__.py ← реестр + фабрика
base.py ← MangaSourceProtocol (Protocol-класс)
readmanga.py ← ReadmangaSource (перенесённый scraper.py)
```
### `base.py` — Protocol-интерфейс
```python
class MangaSourceProtocol(Protocol):
slug: str # "readmanga" — уникальный код в коде
display_name: str # "ReadManga" — для UI
async def get_manga_info(self, page, url) -> Optional[MangaInfo]: ...
async def get_chapter_images_and_download(
self, page, chapter_url, dest_dir, ...
) -> list[Path]: ...
```
### `__init__.py` — реестр и резолвинг
`SourceRegistry` — dict `slug → instance`. Список источников **определяется только в коде** — новый источник добавляется созданием нового класса и регистрацией в реестре. Через API управлять можно **только доменами**.
Экспортирует:
- `registry.get_by_slug(slug)` — по коду источника
- `registry.get_by_id(source_id, db)` — через БД: `sources.id → slug → экземпляр`
- `registry.all()` — полный список зарегистрированных источников (для синхронизации с БД и отображения в UI)
- `get_source_for_url(url, db)` — извлекает домен из URL, ищет в `source_domains`, возвращает адаптер или `None` (домен неизвестен)
### `readmanga.py` — `ReadmangaSource`
Класс с `slug = "readmanga"`. Весь текущий код `scraper.py` переезжает сюда без изменений. CDN-фильтр вынесен в атрибут `cdn_patterns: list[str]`, который можно переопределить настройками из `sources.settings` (JSON). Адаптер самодостаточен.
### Добавление нового источника
Создать файл `src/sources/mysource.py`, реализовать Protocol, зарегистрировать:
```python
# src/sources/__init__.py
from .readmanga import ReadmangaSource
from .mysource import MySource
registry = SourceRegistry([
ReadmangaSource(),
MySource(),
])
```
При следующем старте приложения `StateDB._sync_sources()` автоматически добавит запись нового источника в таблицу `sources` (если её ещё нет). Удалять источники из кода не рекомендуется без предварительной миграции манг.
---
## 2. Изменения БД
### Новые таблицы
```sql
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL, -- "readmanga" — совпадает с кодом
display_name TEXT NOT NULL,
settings TEXT DEFAULT '{}', -- JSON: cdn_patterns и др.
created_at TEXT
);
CREATE TABLE IF NOT EXISTS source_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES sources(id),
domain TEXT UNIQUE NOT NULL -- "readmanga.ru", "readmanga.live"
);
```
### Изменение таблицы `mangas`
```sql
ALTER TABLE mangas ADD COLUMN source_id INTEGER REFERENCES sources(id);
```
Добавляется через существующий паттерн миграций в `StateDB._init()`.
### Синхронизация источников с кодом (`_sync_sources`)
При старте (в `_init()`) вызывается `_sync_sources(registry)`:
1. Для каждого источника из реестра — вставить запись в `sources` если ещё нет (по `slug`).
2. Обновить `display_name` если изменился.
3. **Не удалять** источники из БД даже если они убраны из реестра — только логировать предупреждение.
### Авто-миграция существующих манг
При старте пройтись по всем мангам с `source_id IS NULL`, определить домен из `url`, проставить `source_id` по совпадению в `source_domains`. Если домен не найден — оставить `NULL` (отобразится в UI как «источник не определён»).
### Сидинг доменов ReadManga
```python
DEFAULT_READMANGA_DOMAINS = [
"readmanga.ru", "readmanga.live", "readmanga.me", "readmanga.io",
"3.readmanga.ru",
]
```
Вставляется однократно при первом старте (если нет ни одного домена для `readmanga`).
### Новые методы `StateDB`
- `get_source_by_domain(domain)``dict | None`
- `get_all_sources()``list[dict]` (с вложенными доменами)
- `add_domain(source_id, domain)``bool`
- `remove_domain(source_id, domain)`
- `set_manga_source(manga_url, source_id)` — меняет источник + привязывает домен URL к новому источнику (см. §3)
---
## 3. Рефакторинг `scraper.py` и `worker.py`
### `src/scraper.py` — shim для обратной совместимости
После переноса кода в `ReadmangaSource`:
```python
# src/scraper.py
from .sources.readmanga import ReadmangaSource as _src
from .sources.base import MangaInfo, Chapter
_instance = _src()
async def get_manga_info(page, url):
return await _instance.get_manga_info(page, url)
async def get_chapter_images_and_download(page, chapter_url, dest_dir, **kw):
return await _instance.get_chapter_images_and_download(page, chapter_url, dest_dir, **kw)
```
Это позволяет не ломать `worker.py` и `cli.py` на переходном этапе.
### `src/worker.py` — подключение реестра
В `download_manga(url, fmt, ...)`:
```python
from .sources import get_source_for_url
source = get_source_for_url(url, db)
if source is None:
# Источник не определён — ошибка, уведомить через WS
await ws_broadcast({"type": "source_unknown", "url": url})
return
```
Передавать `source` в `process_chapter()` и далее в функции скачивания.
`check_for_updates()` — аналогично резолвит источник.
### Смена источника + перепривязка домена
```python
async def switch_source(manga_url: str, new_source_id: int, db: StateDB):
"""Меняет источник манги и привязывает домен URL к новому источнику."""
domain = extract_domain(manga_url) # извлечь домен из URL манги
old_domain_source = db.get_source_by_domain(domain)
# Перепривязать домен к новому источнику
if old_domain_source:
db.remove_domain(old_domain_source["id"], domain)
db.add_domain(new_source_id, domain)
# Сменить источник у манги
db.set_manga_source(manga_url, new_source_id)
# Сбросить failed/partial главы → pending
db.reset_failed_chapters(manga_url)
```
Таким образом, при следующем добавлении манги с того же домена источник будет определён автоматически правильно.
---
## 4. API эндпоинты
**Создание и удаление источников через API недоступны** — источники определяются только в коде.
### Источники (только чтение + управление доменами)
| Метод | Путь | Описание |
|-------|------|----------|
| `GET` | `/api/sources` | Список всех источников с доменами |
| `POST` | `/api/sources/{id}/domains` | Добавить домен к источнику `{domain}` |
| `DELETE` | `/api/sources/{id}/domains/{domain}` | Удалить домен |
| `GET` | `/api/resolve-source?url=` | Определить источник по URL → `{source_id, slug, display_name} \| null` |
### Управление мангой
| Метод | Путь | Описание |
|-------|------|----------|
| `POST` | `/api/mangas/switch-source` | Сменить источник `{url, source_id}` (не во время загрузки) |
### Pydantic-модели
```python
class DomainAdd(BaseModel):
domain: str
class SourceOut(BaseModel):
id: int
slug: str
display_name: str
domains: list[str]
settings: dict
class SwitchSourceRequest(BaseModel):
url: str
source_id: int
# домен всегда перепривязывается автоматически
```
---
## 5. Изменения фронтенда
### Диалог добавления манги
1. После ввода URL (debounce 400 мс) → GET `/api/resolve-source?url=...`
2. **Источник найден** → показать badge «Источник: ReadManga» под полем ввода
3. **Источник неизвестен** → показать предупреждение:
> ⚠ Домен не распознан. Выберите источник вручную:
Под предупреждением — `<select>` со списком всех доступных источников. Без выбора источника кнопка «Добавить» неактивна.
После добавления домен URL автоматически привязывается к выбранному источнику (бэкенд делает это в момент добавления манги).
### Карточка манги
- Badge с `source.display_name` рядом с названием (серый, если источник не определён → «Источник неизвестен»)
- Кнопка **«↔ Источник»** — видима всегда, кроме статуса `downloading`; открывает модал:
- Текущий источник (или «не определён»)
- `<select>` со всеми источниками
- Статичное предупреждение под select (всегда видимо): «⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.»
- Кнопка «Применить» → POST `/api/mangas/switch-source`
### Новая вкладка «Настройки»
Добавить четвёртую вкладку в навигацию.
**Подраздел «Источники»** (единственный на данном этапе):
```
┌─ Источники ──────────────────────────────────────────┐
│ Источники определяются в коде приложения. │
│ Здесь можно управлять доменами для каждого источника│
│ │
│ ┌────────────────────────────────────────────────────┐│
│ │ ReadManga slug: readmanga ││
│ │ Домены: ││
│ │ • readmanga.ru [✕] • readmanga.live [✕] ││
│ │ • 3.readmanga.ru [✕] [+ добавить домен] ││
│ └────────────────────────────────────────────────────┘│
│ ┌────────────────────────────────────────────────────┐│
│ │ Другой источник slug: other ││
│ │ ... ││
│ └────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
```
Inline-редактирование:
- `[+ добавить домен]` → inline `<input>` + кнопка «✓» → POST `/api/sources/{id}/domains`
- `[✕]` рядом с доменом → DELETE `/api/sources/{id}/domains/{domain}`
Кнопок «Создать источник» или «Удалить источник» **нет**.
---
## 6. WebSocket события
| `type` | Когда | Данные |
|--------|-------|--------|
| `source_domain_added` | POST /api/sources/{id}/domains | `{source_id, domain}` |
| `source_domain_removed` | DELETE /api/sources/{id}/domains/... | `{source_id, domain}` |
| `source_switched` | POST /api/mangas/switch-source | `{url, old_source_id, new_source_id, domain_rebound: true}` |
| `source_unknown` | Попытка загрузки манги без источника | `{url}` — фронт показывает уведомление |
---
## 7. Решённые вопросы
### 7.1 CDN-паттерны и настройки источника
Каждый источник хранит свои технические настройки (CDN-паттерны и т.п.) **только в коде** внутри класса-адаптера. Поле `settings` в таблице `sources` не используется для пользовательского редактирования — оно остаётся зарезервированным для внутренних нужд адаптера. Никакого UI для редактирования настроек нет.
```python
class ReadmangaSource:
slug = "readmanga"
display_name = "ReadManga"
cdn_patterns = ["one-way.work", "staticfa.", "cdnmanga", "reimg"]
```
### 7.2 Домен, уже привязанный к другому источнику
При смене источника у манги перепривязка домена к новому источнику происходит **автоматически** без дополнительного подтверждения. Флаг `rebind_domain` не нужен.
В UI рядом с `<select>` источника отображается статичное предупреждение:
> ⚠ Домен `xyz.com` будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.
Флаг `rebind_domain` в `SwitchSourceRequest` не нужен — бэкенд всегда перепривязывает домен.
### 7.3 Удалённые из кода источники
При старте логировать предупреждение для каждого источника в БД, которого нет в реестре. В UI такие манги показывают badge **«Источник недоступен»** красным цветом. Загрузка таких манг невозможна до смены источника.
---
## 8. Порядок реализации (этапы)
### Этап 1 — БД (без ломки текущей логики)
- Добавить таблицы `sources`, `source_domains` в `state.py`
- Добавить колонку `source_id` в `mangas`
- Реализовать `_sync_sources(registry)` + сидинг readmanga-доменов
- Авто-миграция существующих манг (проставить `source_id` по домену)
- Новые методы `StateDB`
### Этап 2 — Адаптер + Реестр
- Создать `src/sources/` пакет
- Перенести `scraper.py``src/sources/readmanga.py` (класс `ReadmangaSource`)
- Реализовать `SourceRegistry`, `get_source_for_url()`
- Написать shim `src/scraper.py` (обратная совместимость)
### Этап 3 — Worker + API
- Подключить реестр в `worker.py`
- Добавить `switch_source()` с перепривязкой домена
- Реализовать API эндпоинты (только домены + switch)
- WS-события
### Этап 4 — Фронтенд
- Badge источника на карточках манги
- Автоопределение при вводе URL + предупреждение + ручной выбор для неизвестных доменов
- Диалог смены источника с предупреждением о перепривязке домена
- Вкладка «Настройки → Источники»

View File

@@ -30,7 +30,7 @@ docker compose build
docker compose up -d docker compose up -d
``` ```
Откройте **http://localhost:8000** — вставьте URL манги, выберите формат, нажмите «Добавить». Откройте **http://localhost:8000**войдите под учётными данными из `docker-compose.yml`, вставьте URL манги, выберите формат, нажмите «Добавить».
--- ---
@@ -103,16 +103,40 @@ output/
--- ---
## Авторизация ## Авторизация и пользователи
Задайте в `docker-compose.yml`: Приложение использует многопользовательскую систему с ролями. Доступ к веб-интерфейсу защищён формой входа.
### Системный администратор (bootstrap)
При первом запуске приложение создаёт администратора из переменных окружения:
```yaml ```yaml
- AUTH_LOGIN=ваш_логин - AUTH_LOGIN=ваш_логин
- AUTH_PASSWORD=ваш_пароль - AUTH_PASSWORD=ваш_пароль
``` ```
Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется. Этот пользователь является **системным администратором** (`is_env_admin`):
- Помечен иконкой 🔒 в списке пользователей
- **Пароль нельзя изменить через интерфейс** — только через `AUTH_PASSWORD` в `docker-compose.yml`
- Нельзя удалить
Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
### Управление пользователями
Администратор может управлять пользователями через вкладку **⚙️ Настройки** → раздел **Пользователи**:
- **Создать** пользователя с указанием логина, пароля и роли
- **Изменить** роль или пароль существующего пользователя
- **Удалить** пользователя (кроме системного администратора и самого себя)
### Роли
| Роль | Описание |
|------|---------|
| `admin` | Полный доступ: управление пользователями, удаление и принудительная перезагрузка манг, управление источниками, приоритизация очереди |
| `user` | Может добавлять мангу, управлять только своими загрузками |
--- ---
@@ -120,10 +144,11 @@ output/
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора (создаётся при первом старте) |
| `AUTH_PASSWORD` | — | Пароль системного администратора |
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно | | `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено | | `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) | | `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса |
### Примеры расписания (`UPDATE_SCHEDULE`) ### Примеры расписания (`UPDATE_SCHEDULE`)
@@ -183,5 +208,3 @@ output/
- Физическая папка на диске будет переименована. - Физическая папка на диске будет переименована.
- Пути ко всем уже скачанным файлам обновятся в БД. - Пути ко всем уже скачанным файлам обновятся в БД.
- Дозагрузка новых глав продолжится в переименованную папку. - Дозагрузка новых глав продолжится в переименованную папку.

View File

@@ -23,3 +23,11 @@ services:
# Веб-интерфейс: http://localhost:8000 # Веб-интерфейс: http://localhost:8000
# CLI-команды: # CLI-команды:
# docker compose run --rm --entrypoint "" manga python -m src.cli download <URL> --format cbz # docker compose run --rm --entrypoint "" manga python -m src.cli download <URL> --format cbz
networks:
default:
driver: bridge
ipam:
driver: default
config:
- subnet: 10.33.1.0/24

File diff suppressed because it is too large Load Diff

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
frontend/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

File diff suppressed because it is too large Load Diff

36
src/auth.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Утилиты авторизации: хеширование паролей, генерация токенов сессий.
"""
import hashlib
import hmac
import secrets
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
def hash_password(password: str) -> str:
"""Хеширует пароль: pbkdf2:iterations:salt:key_hex"""
salt = secrets.token_hex(16)
key = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), 260_000
)
return f"pbkdf2:260000:{salt}:{key.hex()}"
def verify_password(password: str, hashed: str) -> bool:
"""Проверяет пароль против сохранённого хеша."""
try:
_, iterations, salt, stored_key = hashed.split(":")
key = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations)
)
return hmac.compare_digest(key.hex(), stored_key)
except Exception:
return False
def generate_session_token() -> str:
"""Генерирует безопасный случайный токен сессии (48 байт)."""
return secrets.token_urlsafe(48)

View File

@@ -95,32 +95,6 @@ class BrowserManager:
page = await ctx.new_page() page = await ctx.new_page()
return ctx, page return ctx, page
async def navigate(self, page: Page, url: str, timeout: int = 60_000,
referer: str | None = None) -> bool:
"""
Открывает URL и ждёт загрузки.
referer — явно выставляется в заголовке запроса (обход защиты сервера).
Возвращает True при успехе.
"""
# Если referer не передан явно — берём домен из url
if referer is None:
from urllib.parse import urlparse
p = urlparse(url)
referer = f"{p.scheme}://{p.netloc}/"
try:
logger.debug("Навигация: {} (referer={})", url, referer)
response = await page.goto(url, wait_until="domcontentloaded",
timeout=timeout, referer=referer)
if response and response.status >= 400:
logger.warning("HTTP {}: {}", response.status, url)
return False
# Ждём завершения JS
await page.wait_for_load_state("networkidle", timeout=timeout)
return True
except Exception as e:
logger.error("Ошибка навигации {}: {}", url, e)
return False
async def __aenter__(self): async def __aenter__(self):
await self.start() await self.start()
return self return self

View File

@@ -16,9 +16,11 @@ from loguru import logger
from tqdm import tqdm from tqdm import tqdm
from .browser import BrowserManager from .browser import BrowserManager
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter from .sources import registry, get_source_for_url
from .exporter import export, ExportFormat from .sources.base import Chapter
from .exporter import export, ExportFormat, MangaMeta
from .state import StateDB from .state import StateDB
from .utils import safe_name, safe_chapter_name
OUTPUT_DIR = Path("/app/output") OUTPUT_DIR = Path("/app/output")
STATE_DIR = Path("/app/state") STATE_DIR = Path("/app/state")
@@ -80,36 +82,41 @@ def download(ctx, url, fmt, chapters, output, resume, force, concurrency):
async def _download(url, fmt, chapters_filter, output_dir, resume, force, concurrency, verbose): async def _download(url, fmt, chapters_filter, output_dir, resume, force, concurrency, verbose):
db = StateDB() db = StateDB()
db.sync_sources(registry)
source = get_source_for_url(url, db)
if source is None:
srcs = registry.all_sources()
source = srcs[0] if srcs else None
if source is None:
logger.error("Источник не определён для URL: {}", url)
db.close()
return
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
ctx, page = await bm.new_page() ctx, page = await bm.new_page()
# 1. Получаем список глав manga = await source.get_manga_info(page, url)
manga = await get_manga_info(page, url)
if not manga: if not manga:
logger.error("Не удалось получить информацию о манге") logger.error("Не удалось получить информацию о манге")
db.close()
return return
manga_dir = output_dir / _safe_name(manga.title) manga_dir = output_dir / safe_name(manga.title_ru or manga.title)
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)
# 2. Сохраняем все главы в БД
for ch in manga.chapters: for ch in manga.chapters:
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume) db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
# 3. Фильтрация
chapters = _filter_chapters(manga.chapters, chapters_filter) chapters = _filter_chapters(manga.chapters, chapters_filter)
logger.info("Будет скачано глав: {}", len(chapters)) logger.info("Будет скачано глав: {}", len(chapters))
# 4. Форматы
formats: list[ExportFormat] = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt] formats: list[ExportFormat] = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
# 5. Скачиваем каждую главу
with tqdm(total=len(chapters), desc="Главы", unit="гл") as pbar: with tqdm(total=len(chapters), desc="Главы", unit="гл") as pbar:
for ch in chapters: for ch in chapters:
pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}") pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}")
# Проверяем статус (resume / force)
if force: if force:
db.reset_chapter(ch.url) db.reset_chapter(ch.url)
elif resume and db.chapter_status(ch.url) == "done": elif resume and db.chapter_status(ch.url) == "done":
@@ -118,10 +125,10 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, force, concur
continue continue
await _process_chapter( await _process_chapter(
bm=bm, ctx=ctx, ch=ch, source=source, ctx=ctx, ch=ch,
manga_url=url, manga=manga, manga_url=url,
manga_dir=manga_dir, formats=formats, manga_dir=manga_dir, formats=formats,
concurrency=concurrency, db=db, force=force, db=db, force=force,
) )
pbar.update(1) pbar.update(1)
@@ -130,16 +137,14 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, force, concur
db.close() db.close()
async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path, async def _process_chapter(source, ctx, ch: Chapter, manga, manga_url: str,
formats: list, concurrency: int, db: StateDB, force: bool = False): manga_dir: Path, formats: list, db: StateDB, force: bool = False):
# Новая страница для каждой главы (чистый контекст)
ch_page = await ctx.new_page() ch_page = await ctx.new_page()
try: try:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
# Открываем главу и скачиваем изображения за один проход image_paths = await source.get_chapter_images_and_download(
image_paths = await get_chapter_images_and_download(
ch_page, ch.url, dest_dir=tmp_path, manga_url=manga_url ch_page, ch.url, dest_dir=tmp_path, manga_url=manga_url
) )
@@ -148,16 +153,27 @@ async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path
db.mark_failed(ch.url) db.mark_failed(ch.url)
return return
ch_name = _safe_chapter_name(ch) ch_name = safe_chapter_name(ch)
ch_meta = MangaMeta(
series=manga.title_ru or manga.title,
series_full=manga.title_full or "",
chapter_title=ch.title,
number=ch.number,
volume=ch.volume,
chapters_total=len(manga.chapters),
pub_status=manga.pub_status,
source_url=manga_url,
summary=manga.description,
genre=", ".join(manga.genres) if manga.genres else "",
)
for fmt in formats: for fmt in formats:
out_file = manga_dir / f"{ch_name}.{fmt}" out_file = manga_dir / f"{ch_name}.{fmt}"
# При --force удаляем старый файл перед перезаписью
if force and out_file.exists(): if force and out_file.exists():
out_file.unlink() out_file.unlink()
logger.debug("Удалён старый файл: {}", out_file.name) logger.debug("Удалён старый файл: {}", out_file.name)
try: try:
export(image_paths, out_file, fmt, manga_dir.name, ch.title) export(image_paths, out_file, fmt, meta=ch_meta)
db.mark_done(ch.url, fmt, str(out_file)) db.mark_done(ch.url, fmt, str(out_file))
except Exception as e: except Exception as e:
logger.error("Ошибка экспорта {}: {}", fmt, e) logger.error("Ошибка экспорта {}: {}", fmt, e)
@@ -180,15 +196,28 @@ def analyze(ctx, url):
async def _analyze(url: str): async def _analyze(url: str):
db = StateDB()
db.sync_sources(registry)
source = get_source_for_url(url, db)
if source is None:
srcs = registry.all_sources()
source = srcs[0] if srcs else None
if source is None:
click.echo("❌ Источник не найден")
db.close()
return
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page() _, page = await bm.new_page()
manga = await get_manga_info(page, url) manga = await source.get_manga_info(page, url)
if not manga: if not manga:
click.echo("Не удалось получить информацию") click.echo("Не удалось получить информацию")
db.close()
return return
click.echo(f"\n📚 Манга: {manga.title}") click.echo(f"\n📚 Манга: {manga.title_ru or manga.title}")
click.echo(f"🔗 URL: {manga.url}") click.echo(f"🔗 URL: {manga.url}")
click.echo(f"📖 Глав: {len(manga.chapters)}\n") click.echo(f"📖 Глав: {len(manga.chapters)}\n")
@@ -198,64 +227,34 @@ async def _analyze(url: str):
if len(manga.chapters) > 20: if len(manga.chapters) > 20:
click.echo(f" ... и ещё {len(manga.chapters) - 20} глав") click.echo(f" ... и ещё {len(manga.chapters) - 20} глав")
# Проверяем одну главу
if manga.chapters: if manga.chapters:
first = manga.chapters[-1] first = manga.chapters[-1]
click.echo(f"\n🔍 Проверяем первую главу: {first.url}") click.echo(f"\n🔍 Проверяем первую главу: {first.url}")
import tempfile
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
paths = await get_chapter_images_and_download( paths = await source.get_chapter_images_and_download(
page, first.url, dest_dir=Path(tmp), manga_url=url page, first.url, dest_dir=Path(tmp), manga_url=url
) )
click.echo(f" Скачано изображений: {len(paths)}") click.echo(f" Скачано изображений: {len(paths)}")
for p in paths[:3]: for p in paths[:3]:
click.echo(f" {p.name} ({p.stat().st_size} байт)") click.echo(f" {p.name} ({p.stat().st_size} байт)")
db.close()
# ── Утилиты ─────────────────────────────────── # ── Утилиты ───────────────────────────────────
def _safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def _safe_chapter_name(ch: Chapter) -> str:
vol = f"v{ch.volume:02d}_" if ch.volume else ""
return f"{vol}ch{ch.number:06.1f}"
def _filter_chapters(chapters: list[Chapter], filter_str: str | None) -> list[Chapter]: def _filter_chapters(chapters: list[Chapter], filter_str: str | None) -> list[Chapter]:
if not filter_str: if not filter_str:
return chapters return chapters
# "1-10" → диапазон
m = re.match(r"^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$", filter_str) m = re.match(r"^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$", filter_str)
if m: if m:
lo, hi = float(m.group(1)), float(m.group(2)) lo, hi = float(m.group(1)), float(m.group(2))
return [c for c in chapters if lo <= c.number <= hi] return [c for c in chapters if lo <= c.number <= hi]
# "1,3,7" → список
nums = {float(x.strip()) for x in filter_str.split(",")} nums = {float(x.strip()) for x in filter_str.split(",")}
return [c for c in chapters if c.number in nums] return [c for c in chapters if c.number in nums]
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View File

@@ -131,8 +131,12 @@ def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
def _export_pdf_pillow(images: list[Path], out: Path): def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images] pil_images = [Image.open(p).convert("RGB") for p in images]
try:
if pil_images: if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF") pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
finally:
for img in pil_images:
img.close()
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta): def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):

View File

@@ -1,665 +1,19 @@
""" """
Парсер readmanga.ru: список глав и URL/байты изображений внутри главы. Обратно-совместимый shim: делегирует вызовы ReadmangaSource.
Не используйте напрямую в новом коде — используйте src.sources.registry.
""" """
import asyncio from .sources.base import Chapter, MangaInfo # noqa: F401 — реэкспорт для импортёров
import re from .sources.readmanga import ReadmangaSource
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from loguru import logger _instance = ReadmangaSource()
from playwright.async_api import Page
from .browser import BrowserManager
# ────────────────────────────────────────────── async def get_manga_info(page, url):
# Модели данных return await _instance.get_manga_info(page, url)
# ──────────────────────────────────────────────
@dataclass
class Chapter:
title: str
url: str
number: float = 0.0
volume: int = 0
@dataclass async def get_chapter_images_and_download(page, chapter_url, dest_dir,
class MangaInfo: manga_url=None, on_page=None):
title: str return await _instance.get_chapter_images_and_download(
url: str page, chapter_url, dest_dir, manga_url=manga_url, on_page=on_page
chapters: list[Chapter] = field(default_factory=list)
pub_status: str = "unknown" # completed / ongoing / unknown
title_ru: str = "" # Только русский тайтл (для папки)
title_full: str = "" # Полный тайтл как на странице
description: str = "" # Описание/синопсис
genres: list[str] = field(default_factory=list) # Жанры
# ──────────────────────────────────────────────
# Страница манги — список глав
# ──────────────────────────────────────────────
async def get_manga_info(page: Page, url: str) -> Optional[MangaInfo]:
"""Открывает страницу манги и возвращает список всех глав."""
logger.info("Загружаем страницу манги: {}", url)
ok = await _navigate(page, url)
if not ok:
return None
title_full = await page.title()
title_full = re.sub(r"\s*[-|].*$", "", title_full).strip()
# Пробуем взять русский тайтл напрямую из DOM
title_ru = await _extract_ru_title_from_dom(page)
if not title_ru:
title_ru = _parse_ru_title(title_full)
logger.info("Манга: {} | ru: {}", title_full, title_ru)
pub_status = await _extract_pub_status(page)
logger.info("Статус выпуска: {}", pub_status)
description = await _extract_description(page)
genres = await _extract_genres(page)
await _expand_chapters(page)
chapters = await _extract_chapters(page)
if not chapters:
chapters = await _extract_chapters_alt(page)
logger.info("Найдено глав: {}", len(chapters))
return MangaInfo(
title=title_ru or title_full,
url=url,
chapters=chapters,
pub_status=pub_status,
title_ru=title_ru,
title_full=title_full,
description=description,
genres=genres,
) )
async def _extract_ru_title_from_dom(page: Page) -> str:
"""Ищет русский тайтл в структуре страницы readmanga."""
try:
result = await page.evaluate("""
() => {
// readmanga: основной тайтл в span.name внутри .names
const selectors = [
'.names .name',
'h1.manga-title',
'h1 .name',
'.name-block .name',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()
except Exception:
return ""
def _parse_ru_title(full_title: str) -> str:
"""Извлекает русский тайтл из полной строки тайтла.
Примеры:
'Манга Режим — АД. Хардкорный геймер ... (Hellmode)''Режим — АД. Хардкорный геймер ...'
'Манга Магическая битва (Sorcery Fight) Гэгэ онлайн''Магическая битва'
'Авантюрист Monster Eater Adventurer''Авантюрист'
"""
t = full_title.strip()
# Убираем префикс "Манга "
t = re.sub(r'^Манга\s+', '', t).strip()
# Берём только до первой скобки (начало английского тайтла)
t = re.split(r'\s*[\(\[]', t)[0].strip()
# Убираем суффикс " онлайн"
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
# Обрезаем хвост из латинских слов.
# Правило: стоп только на токене содержащем латиницу (a-zA-Z).
# Пунктуация между кириллическими словами (—, , ., :, !) — сохраняем.
words = t.split()
result = []
for w in words:
if re.search(r'[а-яёА-ЯЁ]', w):
result.append(w)
elif re.search(r'[a-zA-Z]', w):
# Первое латинское слово после кириллических — обрезаем здесь
if result:
break
else:
# Чисто пунктуационный токен (—, , ., :, …)
# Добавляем только если уже есть кириллические слова (связка внутри)
if result:
result.append(w)
# Убираем висячую пунктуацию в конце (если последнее слово — не кириллица)
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
result.pop()
if result:
t = ' '.join(result)
return t
async def _extract_pub_status(page: Page) -> str:
"""Извлекает статус выпуска: completed / ongoing / unknown."""
try:
result = await page.evaluate("""
() => {
// readmanga хранит статус в .elem_status .value или похожих блоках
const statusSelectors = [
'.elem_status .value',
'.manga-info .status',
'[class*="status"] .value',
'.property .status',
];
for (const sel of statusSelectors) {
const el = document.querySelector(sel);
if (el) {
const t = el.textContent.toLowerCase();
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
}
}
// Fallback: сканируем весь текст страницы
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
if (bodyText.includes('продолжается')) return 'ongoing';
return 'unknown';
}
""")
return result or "unknown"
except Exception:
return "unknown"
async def _extract_description(page: Page) -> str:
"""Извлекает описание/синопсис манги."""
try:
result = await page.evaluate("""
() => {
const selectors = [
'.manga-description',
'.elem_descr .value',
'#tab-description .description-text',
'.description',
'[itemprop="description"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()[:2000] # обрезаем до 2000 символов
except Exception:
return ""
async def _extract_genres(page: Page) -> list[str]:
"""Извлекает список жанров манги."""
try:
result = await page.evaluate("""
() => {
const selectors = [
'.elem_genre .value a',
'.genres a',
'[itemprop="genre"]',
'.genre-list a',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
async def _navigate(page: Page, url: str, retries: int = 3,
referer: str | None = None) -> bool:
from urllib.parse import urlparse
if referer is None:
p = urlparse(url)
referer = f"{p.scheme}://{p.netloc}/"
for attempt in range(1, retries + 1):
try:
resp = await page.goto(url, wait_until="domcontentloaded",
timeout=60_000, referer=referer)
if resp and resp.status >= 400:
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
await asyncio.sleep(3 * attempt)
continue
try:
await page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
return True
except Exception as e:
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
await asyncio.sleep(3 * attempt)
return False
async def _expand_chapters(page: Page):
for sel in ["a.chapter-link.all", "button:has-text('Все главы')",
"a:has-text('Все главы')"]:
try:
el = page.locator(sel).first
if await el.is_visible(timeout=2000):
await el.click()
await page.wait_for_load_state("networkidle", timeout=10_000)
return
except Exception:
pass
async def _extract_chapters(page: Page) -> list[Chapter]:
"""Основной парсер: #chapters-list → tr.item-row → td[data-num] a.chapter-link"""
rows = await page.query_selector_all("#chapters-list tr.item-row")
chapters = []
for row in rows:
link = await row.query_selector("td[class*='item-title'] a")
if not link:
continue
href = await link.get_attribute("href") or ""
text = (await link.inner_text()).strip()
if not href:
continue
td = await row.query_selector("td[data-num]")
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
number = num_raw / 10.0
full_url = href if href.startswith("http") else _base_url(page.url) + href
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
return chapters
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
result = await page.evaluate("""
() => {
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
.filter(x => x.href && x.text);
}
""")
return [Chapter(title=x["text"], url=x["href"],
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
for x in result]
def _base_url(url: str) -> str:
m = re.match(r"(https?://[^/]+)", url)
return m.group(1) if m else "https://readmanga.ru"
def _parse_num(text: str) -> float:
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
return float(m.group()) if m else 0.0
def _parse_vol(text: str) -> int:
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
return int(m.group(1)) if m else 0
# ──────────────────────────────────────────────
# Страница главы — получение URL изображений
# ──────────────────────────────────────────────
async def _extract_images_from_js(page: Page) -> list[str]:
"""
Извлекает URL из rm_h.readerInit(chapterInfo, [[base, '', path, w, h], ...]).
Считает скобки для точного захвата массива.
"""
try:
result = await page.evaluate("""
() => {
for (const s of document.querySelectorAll('script')) {
const text = s.textContent || '';
const mi = text.indexOf('readerInit');
if (mi === -1) continue;
const ai = text.indexOf('[', mi);
if (ai === -1) continue;
let depth = 0, end = -1;
for (let i = ai; i < text.length; i++) {
if (text[i] === '[') depth++;
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
}
if (end === -1) continue;
try {
const arr = eval(text.slice(ai, end));
if (Array.isArray(arr) && arr.length)
return arr.map(item => Array.isArray(item) && item.length >= 3
? item[0] + item[2] : null).filter(Boolean);
} catch(e) {}
}
return [];
}
""")
if result:
logger.debug("JS readerInit нашёл {} изображений", len(result))
return result or []
except Exception as e:
logger.debug("JS-метод не сработал: {}", e)
return []
async def _extract_images_from_dom(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
const found = Array.from(document.querySelectorAll(sel));
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
def _get_ext(url: str) -> str:
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
if m:
ext = m.group(1).lower()
return ".jpg" if ext == "jpeg" else f".{ext}"
return ".jpg"
# ──────────────────────────────────────────────
# Скачивание главы
# ──────────────────────────────────────────────
async def get_chapter_images_and_download(
page: Page,
chapter_url: str,
dest_dir: Path,
manga_url: str | None = None,
on_page: object = None,
) -> list[Path]:
"""
1. Открывает страницу главы (устанавливает DDoS-Guard cookies для CDN).
2. Извлекает список URL из readerInit.
3. Перехватывает img-запросы через page.route() + route.fetch()
(браузерный стек — правильные Sec-Fetch-* заголовки, cookies).
4. Пролистывает читалку клавишей ArrowRight чтобы загрузить все страницы.
5. Retry для страниц с timeout через JS fetch.
"""
t_start = time.monotonic()
ch_id = chapter_url.split("/")[-1] # короткий идентификатор для логов
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
from urllib.parse import urlparse
parsed = urlparse(chapter_url)
parts = parsed.path.strip("/").split("/")
manga_slug = parts[0] if parts else ""
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
dest_dir.mkdir(parents=True, exist_ok=True)
def _base(u: str) -> str:
return u.split("?")[0]
# Баннеры/рекламные изображения — игнорируем без логирования
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
def _is_manga_image(url: str) -> bool:
base = _base(url)
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
return False
if "resrmr." in url or "/static/" in url:
return False
return bool(re.search(r"one-way\.work|staticfa\.|rm\.one-way|cdnmanga|reimg", url, re.I))
captured: dict[str, bytes] = {} # base_url → bytes
route_errors: dict[str, str] = {} # base_url → текст ошибки
route_statuses: dict[str, int] = {} # base_url → HTTP status (не 200/206)
lock = asyncio.Lock()
async def route_handler(route, request):
url = request.url
base = _base(url)
if not _is_manga_image(url):
await route.continue_()
return
if BANNER_RE.search(base):
await route.continue_()
return
async with lock:
already = base in captured
if already:
await route.continue_()
return
fname = base.split("/")[-1]
try:
response = await route.fetch()
status = response.status
body = await response.body()
if body and len(body) > 500 and status in (200, 206):
async with lock:
if base not in captured:
captured[base] = body
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
async with lock:
route_statuses[base] = status
if status not in (200, 206):
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
ch_id, status, fname, base[-70:])
else:
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
ch_id, len(body), fname)
await route.fulfill(response=response)
except Exception as e:
err = str(e)
async with lock:
route_errors[base] = err
is_timeout = "timeout" in err.lower()
level = logger.warning if is_timeout else logger.warning
level("[{}] route.fetch {} '{}': {}",
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
try:
await route.continue_()
except Exception:
pass
await page.route("**/*", route_handler)
# 1. Открываем главу
ok = await _navigate(page, load_url, referer=referer)
if not ok:
await page.unroute("**/*", route_handler)
logger.error("[{}] Не удалось открыть главу после всех retry: {}", ch_id, chapter_url)
return []
# 2. Ждём readerInit
try:
await page.wait_for_function(
"() => Array.from(document.querySelectorAll('script'))"
".some(s => s.textContent.includes('readerInit'))",
timeout=15_000,
)
except Exception as e:
logger.warning("[{}] readerInit не появился за 15с ({}). "
"Продолжаем через DOM-fallback.", ch_id, str(e)[:80])
# 3. Извлекаем список URL
image_urls = await _extract_images_from_js(page)
if not image_urls:
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
image_urls = await _extract_images_from_dom(page)
if not image_urls:
await page.unroute("**/*", route_handler)
try:
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
except Exception:
page_info = "?"
logger.error("[{}] Список изображений пуст. Текущая страница: {}", ch_id, page_info)
return []
logger.info("[{}] Найдено изображений: {}", ch_id, len(image_urls))
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
total = len(image_urls)
def _count_matched() -> int:
count = 0
for base_url in captured:
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
count += 1
return count
# 4. Пролистываем читалку
await asyncio.sleep(1)
stall_count = 0
prev_done = -1
for i in range(total + 20):
done = _count_matched()
if done >= total:
break
try:
await page.keyboard.press("ArrowRight")
await asyncio.sleep(0.5)
except Exception as e:
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
break
if i % 20 == 19:
done = _count_matched()
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
if done == prev_done:
stall_count += 1
if stall_count >= 3:
logger.warning("[{}] Прогресс завис ({}/{}) после {} листаний — прерываем",
ch_id, done, total, i + 1)
break
else:
stall_count = 0
prev_done = done
# Финальное ожидание
await asyncio.sleep(3)
# 5. Retry для страниц с timeout через браузерный JS fetch
async with lock:
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout через JS fetch...",
ch_id, len(timeout_bases))
for retry_base in timeout_bases:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
try:
data_b64 = await page.evaluate("""async (url) => {
try {
const r = await fetch(url, {credentials: 'include'});
if (!r.ok) return null;
const buf = await r.arrayBuffer();
const bytes = new Uint8Array(buf);
let bin = '';
for (let b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
} catch(e) { return null; }
}""", retry_base)
if data_b64:
import base64
body = base64.b64decode(data_b64)
if len(body) > 500:
async with lock:
captured[retry_base] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] Retry вернул {} байт для '{}' — игнорируем",
ch_id, len(body), fname)
else:
logger.warning("[{}] Retry вернул null для '{}' | {}",
ch_id, fname, retry_base[-70:])
except Exception as e2:
logger.warning("[{}] Retry JS ошибка для '{}': {}", ch_id, fname, e2)
await page.unroute("**/*", route_handler)
done = _count_matched()
elapsed = time.monotonic() - t_start
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
# 6. Сохраняем в правильном порядке
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
paths: dict[int, Path] = {}
unmatched_other: list[str] = []
for base_url, body in captured.items():
idx = url_to_idx.get(base_url)
if idx is None:
fname = base_url.split("/")[-1]
idx = filename_to_idx.get(fname)
if idx is None:
if not BANNER_RE.search(base_url):
unmatched_other.append(base_url.split("/")[-1])
continue
ext = _get_ext(base_url)
p = dest_dir / f"{idx:04d}{ext}"
p.write_bytes(body)
paths[idx] = p
if unmatched_other:
logger.debug("[{}] Перехвачено, но не совпало с readerInit ({}): {}",
ch_id, len(unmatched_other), unmatched_other)
# 7. Итоговый отчёт по пропущенным страницам
missing_idxs = [i for i in range(total) if i not in paths]
if missing_idxs:
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
missing_full = [_base(image_urls[i]) for i in missing_idxs]
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
if missing_full[j] in route_errors
and "timeout" in route_errors[missing_full[j]].lower()]
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
for j, i in enumerate(missing_idxs)
if missing_full[j] in route_statuses]
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
if missing_full[j] not in route_errors
and missing_full[j] not in route_statuses]
reasons = []
if timeout_miss:
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
if http_miss:
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
if unrcv:
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
logger.warning(
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
ch_id, len(missing_idxs), total,
[i + 1 for i in missing_idxs],
" | ".join(reasons) if reasons else "неизвестно",
)
logger.debug("[{}] Полные URL пропущенных: {}", ch_id, missing_full)
return [paths[i] for i in sorted(paths.keys())]

74
src/sources/__init__.py Normal file
View File

@@ -0,0 +1,74 @@
"""
Реестр источников манги.
Для добавления нового источника:
1. Создать файл src/sources/mysource.py с классом, реализующим MangaSourceProtocol
2. Импортировать его здесь и добавить в список SOURCES
"""
from urllib.parse import urlparse
from typing import Optional
from .base import MangaSourceProtocol
from .readmanga import ReadmangaSource
# ── Регистрация источников ─────────────────────
# Добавьте новые источники сюда:
SOURCES: list = [
ReadmangaSource(),
]
# Быстрый поиск по slug
_BY_SLUG: dict[str, object] = {s.slug: s for s in SOURCES}
class SourceRegistry:
"""Реестр источников. Источники определяются только в коде."""
def get_by_slug(self, slug: str) -> Optional[object]:
return _BY_SLUG.get(slug)
def get_by_db_id(self, source_id: int, db) -> Optional[object]:
"""Резолвит адаптер через БД: source_id → slug → экземпляр."""
row = db.get_source_by_id(source_id)
if not row:
return None
return _BY_SLUG.get(row["slug"])
def all_sources(self) -> list:
return list(SOURCES)
def all_slugs(self) -> list[str]:
return [s.slug for s in SOURCES]
registry = SourceRegistry()
def get_source_for_url(url: str, db) -> Optional[object]:
"""
Определяет источник по домену URL.
Ищет домен в таблице source_domains → возвращает адаптер.
Если домен не зарегистрирован — возвращает None.
"""
try:
domain = urlparse(url).netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
row = db.get_source_by_domain(domain)
if not row:
return None
return _BY_SLUG.get(row["slug"])
except Exception:
return None
def extract_domain(url: str) -> str:
"""Извлекает домен без www."""
try:
domain = urlparse(url).netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""

58
src/sources/base.py Normal file
View File

@@ -0,0 +1,58 @@
"""
Базовые модели данных и Protocol-интерфейс для источников манги.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Protocol, runtime_checkable
from playwright.async_api import Page
# ──────────────────────────────────────────────
# Модели данных (общие для всех источников)
# ──────────────────────────────────────────────
@dataclass
class Chapter:
title: str
url: str
number: float = 0.0
volume: int = 0
@dataclass
class MangaInfo:
title: str
url: str
chapters: list[Chapter] = field(default_factory=list)
pub_status: str = "unknown" # completed / ongoing / unknown
title_ru: str = ""
title_full: str = ""
description: str = ""
genres: list[str] = field(default_factory=list)
# ──────────────────────────────────────────────
# Интерфейс источника
# ──────────────────────────────────────────────
@runtime_checkable
class MangaSourceProtocol(Protocol):
slug: str # уникальный код источника в коде ("readmanga")
display_name: str # название для UI ("ReadManga")
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
"""Возвращает информацию о манге и список глав."""
...
async def get_chapter_images_and_download(
self,
page: Page,
chapter_url: str,
dest_dir: Path,
manga_url: Optional[str] = None,
on_page: object = None,
) -> list[Path]:
"""Скачивает страницы главы в dest_dir и возвращает список путей."""
...

586
src/sources/readmanga.py Normal file
View File

@@ -0,0 +1,586 @@
"""
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
"""
import asyncio
import base64
import re
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from loguru import logger
from playwright.async_api import Page
from .base import Chapter, MangaInfo
class ReadmangaSource:
slug = "readmanga"
display_name = "ReadManga"
# CDN-домены из которых принимаем картинки глав
cdn_patterns = ["one-way.work", "staticfa.", "rm.one-way", "cdnmanga", "reimg"]
# ──────────────────────────────────────────────
# Страница манги — список глав
# ──────────────────────────────────────────────
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
"""Открывает страницу манги и возвращает список всех глав."""
logger.info("Загружаем страницу манги: {}", url)
ok = await _navigate(page, url)
if not ok:
return None
title_full = await page.title()
title_full = re.sub(r"\s*[-|].*$", "", title_full).strip()
title_ru = await _extract_ru_title_from_dom(page)
if not title_ru:
title_ru = _parse_ru_title(title_full)
logger.info("Манга: {} | ru: {}", title_full, title_ru)
pub_status = await _extract_pub_status(page)
logger.info("Статус выпуска: {}", pub_status)
description = await _extract_description(page)
genres = await _extract_genres(page)
await _expand_chapters(page)
chapters = await _extract_chapters(page)
if not chapters:
chapters = await _extract_chapters_alt(page)
logger.info("Найдено глав: {}", len(chapters))
return MangaInfo(
title=title_ru or title_full,
url=url,
chapters=chapters,
pub_status=pub_status,
title_ru=title_ru,
title_full=title_full,
description=description,
genres=genres,
)
# ──────────────────────────────────────────────
# Скачивание главы
# ──────────────────────────────────────────────
async def get_chapter_images_and_download(
self,
page: Page,
chapter_url: str,
dest_dir: Path,
manga_url: Optional[str] = None,
on_page: object = None,
) -> list[Path]:
"""
1. Открывает страницу главы.
2. Извлекает список URL из readerInit.
3. Перехватывает img-запросы через page.route().
4. Пролистывает читалку клавишей ArrowRight.
5. Retry для страниц с timeout через JS fetch.
"""
cdn_patterns = self.cdn_patterns
t_start = time.monotonic()
ch_id = chapter_url.split("/")[-1]
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
parsed = urlparse(chapter_url)
parts = parsed.path.strip("/").split("/")
manga_slug = parts[0] if parts else ""
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
dest_dir.mkdir(parents=True, exist_ok=True)
def _base(u: str) -> str:
return u.split("?")[0]
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
def _is_manga_image(url: str) -> bool:
base = _base(url)
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
return False
if "resrmr." in url or "/static/" in url:
return False
pattern = "|".join(re.escape(p) for p in cdn_patterns)
return bool(re.search(pattern, url, re.I))
captured: dict[str, bytes] = {}
route_errors: dict[str, str] = {}
route_statuses: dict[str, int] = {}
lock = asyncio.Lock()
async def route_handler(route, request):
url = request.url
base = _base(url)
if not _is_manga_image(url):
await route.continue_()
return
if BANNER_RE.search(base):
await route.continue_()
return
async with lock:
already = base in captured
if already:
await route.continue_()
return
fname = base.split("/")[-1]
try:
response = await route.fetch()
status = response.status
body = await response.body()
if body and len(body) > 500 and status in (200, 206):
async with lock:
if base not in captured:
captured[base] = body
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
async with lock:
route_statuses[base] = status
if status not in (200, 206):
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
ch_id, status, fname, base[-70:])
else:
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
ch_id, len(body), fname)
await route.fulfill(response=response)
except Exception as e:
err = str(e)
async with lock:
route_errors[base] = err
is_timeout = "timeout" in err.lower()
logger.warning("[{}] route.fetch {} '{}': {}",
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
try:
await route.continue_()
except Exception:
pass
await page.route("**/*", route_handler)
ok = await _navigate(page, load_url, referer=referer)
if not ok:
await page.unroute("**/*", route_handler)
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
return []
try:
await page.wait_for_function(
"() => Array.from(document.querySelectorAll('script'))"
".some(s => s.textContent.includes('readerInit'))",
timeout=15_000,
)
except Exception as e:
logger.warning("[{}] readerInit не появился за 15с ({}). DOM-fallback.", ch_id, str(e)[:80])
image_urls = await _extract_images_from_js(page)
if not image_urls:
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
image_urls = await _extract_images_from_dom(page)
if not image_urls:
await page.unroute("**/*", route_handler)
try:
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
except Exception:
page_info = "?"
logger.error("[{}] Список изображений пуст. Страница: {}", ch_id, page_info)
return []
logger.info("[{}] Найдено изображений: {}", ch_id, len(image_urls))
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
total = len(image_urls)
def _count_matched() -> int:
count = 0
for base_url in captured:
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
count += 1
return count
await asyncio.sleep(1)
stall_count = 0
prev_done = -1
for i in range(total + 20):
done = _count_matched()
if done >= total:
break
try:
await page.keyboard.press("ArrowRight")
await asyncio.sleep(0.5)
except Exception as e:
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
break
if i % 20 == 19:
done = _count_matched()
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
if done == prev_done:
stall_count += 1
if stall_count >= 3:
logger.warning("[{}] Прогресс завис ({}/{}) — прерываем", ch_id, done, total)
break
else:
stall_count = 0
prev_done = done
await asyncio.sleep(3)
# Retry timeout через JS fetch
async with lock:
timeout_bases = [u for u, e in route_errors.items()
if "timeout" in e.lower() and u not in captured]
if timeout_bases:
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
for retry_base in timeout_bases:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
try:
data_b64 = await page.evaluate("""async (url) => {
try {
const r = await fetch(url, {credentials: 'include'});
if (!r.ok) return null;
const buf = await r.arrayBuffer();
const bytes = new Uint8Array(buf);
let bin = '';
for (let b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
} catch(e) { return null; }
}""", retry_base)
if data_b64:
body = base64.b64decode(data_b64)
if len(body) > 500:
async with lock:
captured[retry_base] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body))
else:
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
except Exception as e2:
logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2)
await page.unroute("**/*", route_handler)
done = _count_matched()
elapsed = time.monotonic() - t_start
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
paths: dict[int, Path] = {}
unmatched_other: list[str] = []
for base_url, body in captured.items():
idx = url_to_idx.get(base_url)
if idx is None:
fname = base_url.split("/")[-1]
idx = filename_to_idx.get(fname)
if idx is None:
if not BANNER_RE.search(base_url):
unmatched_other.append(base_url.split("/")[-1])
continue
ext = _get_ext(base_url)
p = dest_dir / f"{idx:04d}{ext}"
p.write_bytes(body)
paths[idx] = p
if unmatched_other:
logger.debug("[{}] Не совпало с readerInit ({}): {}", ch_id, len(unmatched_other), unmatched_other)
missing_idxs = [i for i in range(total) if i not in paths]
if missing_idxs:
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
missing_full = [_base(image_urls[i]) for i in missing_idxs]
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
if missing_full[j] in route_errors
and "timeout" in route_errors[missing_full[j]].lower()]
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
for j, i in enumerate(missing_idxs)
if missing_full[j] in route_statuses]
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
if missing_full[j] not in route_errors
and missing_full[j] not in route_statuses]
reasons = []
if timeout_miss:
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
if http_miss:
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
if unrcv:
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
logger.warning(
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
ch_id, len(missing_idxs), total,
[i + 1 for i in missing_idxs],
" | ".join(reasons) if reasons else "неизвестно",
)
return [paths[i] for i in sorted(paths.keys())]
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)
# ──────────────────────────────────────────────
async def _navigate(page: Page, url: str, retries: int = 3,
referer: str | None = None) -> bool:
if referer is None:
p = urlparse(url)
referer = f"{p.scheme}://{p.netloc}/"
for attempt in range(1, retries + 1):
try:
resp = await page.goto(url, wait_until="domcontentloaded",
timeout=60_000, referer=referer)
if resp and resp.status >= 400:
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
await asyncio.sleep(3 * attempt)
continue
try:
await page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
return True
except Exception as e:
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
await asyncio.sleep(3 * attempt)
return False
async def _extract_ru_title_from_dom(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
const selectors = [
'.names .name', 'h1.manga-title', 'h1 .name', '.name-block .name',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()
except Exception:
return ""
def _parse_ru_title(full_title: str) -> str:
t = full_title.strip()
t = re.sub(r'^Манга\s+', '', t).strip()
t = re.split(r'\s*[\(\[]', t)[0].strip()
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
words = t.split()
result = []
for w in words:
if re.search(r'[а-яёА-ЯЁ]', w):
result.append(w)
elif re.search(r'[a-zA-Z]', w):
if result:
break
else:
if result:
result.append(w)
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
result.pop()
if result:
t = ' '.join(result)
return t
async def _extract_pub_status(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
const statusSelectors = [
'.elem_status .value', '.manga-info .status',
'[class*="status"] .value', '.property .status',
];
for (const sel of statusSelectors) {
const el = document.querySelector(sel);
if (el) {
const t = el.textContent.toLowerCase();
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
}
}
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
if (bodyText.includes('продолжается')) return 'ongoing';
return 'unknown';
}
""")
return result or "unknown"
except Exception:
return "unknown"
async def _extract_description(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
const selectors = [
'.manga-description', '.elem_descr .value',
'#tab-description .description-text', '.description',
'[itemprop="description"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) return el.textContent.trim();
}
return '';
}
""")
return (result or "").strip()[:2000]
except Exception:
return ""
async def _extract_genres(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
const selectors = [
'.elem_genre .value a', '.genres a',
'[itemprop="genre"]', '.genre-list a',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
async def _expand_chapters(page: Page):
for sel in ["a.chapter-link.all", "button:has-text('Все главы')", "a:has-text('Все главы')"]:
try:
el = page.locator(sel).first
if await el.is_visible(timeout=2000):
await el.click()
await page.wait_for_load_state("networkidle", timeout=10_000)
return
except Exception:
pass
async def _extract_chapters(page: Page) -> list[Chapter]:
rows = await page.query_selector_all("#chapters-list tr.item-row")
chapters = []
for row in rows:
link = await row.query_selector("td[class*='item-title'] a")
if not link:
continue
href = await link.get_attribute("href") or ""
text = (await link.inner_text()).strip()
if not href:
continue
td = await row.query_selector("td[data-num]")
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
number = num_raw / 10.0
full_url = href if href.startswith("http") else _base_url(page.url) + href
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
return chapters
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
result = await page.evaluate("""
() => {
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
.filter(x => x.href && x.text);
}
""")
return [Chapter(title=x["text"], url=x["href"],
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
for x in result]
async def _extract_images_from_js(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
for (const s of document.querySelectorAll('script')) {
const text = s.textContent || '';
const mi = text.indexOf('readerInit');
if (mi === -1) continue;
const ai = text.indexOf('[', mi);
if (ai === -1) continue;
let depth = 0, end = -1;
for (let i = ai; i < text.length; i++) {
if (text[i] === '[') depth++;
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
}
if (end === -1) continue;
try {
const arr = eval(text.slice(ai, end));
if (Array.isArray(arr) && arr.length)
return arr.map(item => Array.isArray(item) && item.length >= 3
? item[0] + item[2] : null).filter(Boolean);
} catch(e) {}
}
return [];
}
""")
if result:
logger.debug("JS readerInit нашёл {} изображений", len(result))
return result or []
except Exception as e:
logger.debug("JS-метод не сработал: {}", e)
return []
async def _extract_images_from_dom(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
const found = Array.from(document.querySelectorAll(sel));
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
def _get_ext(url: str) -> str:
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
if m:
ext = m.group(1).lower()
return ".jpg" if ext == "jpeg" else f".{ext}"
return ".jpg"
def _base_url(url: str) -> str:
m = re.match(r"(https?://[^/]+)", url)
return m.group(1) if m else "https://readmanga.ru"
def _parse_num(text: str) -> float:
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
return float(m.group()) if m else 0.0
def _parse_vol(text: str) -> int:
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
return int(m.group(1)) if m else 0

View File

@@ -1,14 +1,25 @@
""" """
Хранение состояния скачивания в SQLite. Хранение состояния скачивания в SQLite.
""" """
import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import urlparse
DB_PATH = Path("/app/state/progress.db") DB_PATH = Path("/app/state/progress.db")
# Домены ReadManga по умолчанию (сидинг при первом запуске)
_DEFAULT_READMANGA_DOMAINS = [
"readmanga.ru",
"readmanga.live",
"readmanga.me",
"readmanga.io",
"3.readmanga.ru",
]
class StateDB: class StateDB:
def __init__(self, db_path: Path = DB_PATH): def __init__(self, db_path: Path = DB_PATH):
@@ -68,6 +79,41 @@ class StateDB:
created_at TEXT created_at TEXT
) )
""") """)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
settings TEXT DEFAULT '{}',
created_at TEXT
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS source_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES sources(id),
domain TEXT UNIQUE NOT NULL
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_env_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
updated_at TEXT
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT,
expires_at TEXT
)
""")
# Migrate old DB: add missing columns # Migrate old DB: add missing columns
migrations = [ migrations = [
("chapters", "pages_total", "INTEGER DEFAULT 0"), ("chapters", "pages_total", "INTEGER DEFAULT 0"),
@@ -80,6 +126,9 @@ class StateDB:
("mangas", "started_at", "TEXT"), ("mangas", "started_at", "TEXT"),
("mangas", "finished_at", "TEXT"), ("mangas", "finished_at", "TEXT"),
("mangas", "folder_name", "TEXT"), ("mangas", "folder_name", "TEXT"),
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -88,17 +137,185 @@ class StateDB:
pass pass
self.conn.commit() self.conn.commit()
def sync_sources(self, registry) -> None:
"""
Синхронизирует таблицу sources с реестром из кода.
Вызывается при старте приложения.
При первом запуске создаёт записи и засеивает домены ReadManga.
"""
from loguru import logger
for source in registry.all_sources():
existing = self.conn.execute(
"SELECT id, display_name FROM sources WHERE slug=?", (source.slug,)
).fetchone()
if not existing:
self.conn.execute(
"INSERT INTO sources (slug, display_name, settings, created_at) VALUES (?,?,?,?)",
(source.slug, source.display_name, "{}", _now())
)
logger.info("Источник добавлен в БД: {} ({})", source.display_name, source.slug)
else:
if existing["display_name"] != source.display_name:
self.conn.execute(
"UPDATE sources SET display_name=? WHERE slug=?",
(source.display_name, source.slug)
)
self.conn.commit()
# Сидинг доменов ReadManga при первом запуске
rm = self.conn.execute("SELECT id FROM sources WHERE slug='readmanga'").fetchone()
if rm:
count = self.conn.execute(
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (rm["id"],)
).fetchone()[0]
if count == 0:
for domain in _DEFAULT_READMANGA_DOMAINS:
try:
self.conn.execute(
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
(rm["id"], domain)
)
except Exception:
pass
self.conn.commit()
logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS))
# Логируем источники в БД без кода (не в реестре)
known_slugs = set(registry.all_slugs())
db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()]
for slug in db_slugs:
if slug not in known_slugs:
logger.warning("Источник '{}' есть в БД, но отсутствует в реестре — манги недоступны", slug)
def migrate_manga_sources(self) -> int:
"""
Авто-миграция: проставляет source_id для манг с source_id IS NULL.
Определяет источник по домену URL через source_domains.
Возвращает количество обновлённых манг.
"""
nulls = self.conn.execute(
"SELECT url FROM mangas WHERE source_id IS NULL"
).fetchall()
updated = 0
for row in nulls:
url = row["url"]
domain = _extract_domain(url)
source_row = self.get_source_by_domain(domain)
if source_row:
self.conn.execute(
"UPDATE mangas SET source_id=? WHERE url=?",
(source_row["id"], url)
)
updated += 1
if updated:
self.conn.commit()
return updated
# ── Sources ───────────────────────────────────
def get_source_by_id(self, source_id: int) -> Optional[dict]:
row = self.conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
return dict(row) if row else None
def get_source_by_slug(self, slug: str) -> Optional[dict]:
row = self.conn.execute("SELECT * FROM sources WHERE slug=?", (slug,)).fetchone()
return dict(row) if row else None
def get_source_by_domain(self, domain: str) -> Optional[dict]:
"""Возвращает запись source по домену (через source_domains JOIN)."""
row = self.conn.execute("""
SELECT s.* FROM sources s
JOIN source_domains sd ON sd.source_id = s.id
WHERE sd.domain=?
""", (domain.lower(),)).fetchone()
return dict(row) if row else None
def get_all_sources(self) -> list[dict]:
"""Возвращает все источники с вложенным списком доменов."""
sources = self.conn.execute("SELECT * FROM sources ORDER BY id").fetchall()
result = []
for s in sources:
s_dict = dict(s)
domains = self.conn.execute(
"SELECT domain FROM source_domains WHERE source_id=? ORDER BY domain",
(s["id"],)
).fetchall()
s_dict["domains"] = [d["domain"] for d in domains]
try:
s_dict["settings"] = json.loads(s_dict.get("settings") or "{}")
except Exception:
s_dict["settings"] = {}
result.append(s_dict)
return result
def add_domain(self, source_id: int, domain: str) -> bool:
"""Добавляет домен к источнику. Возвращает False если уже существует."""
domain = domain.lower().strip()
try:
self.conn.execute(
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
(source_id, domain)
)
self.conn.commit()
return True
except Exception:
return False
def remove_domain(self, source_id: int, domain: str) -> bool:
"""Удаляет домен у источника. Возвращает True если удалён."""
cur = self.conn.execute(
"DELETE FROM source_domains WHERE source_id=? AND domain=?",
(source_id, domain.lower())
)
self.conn.commit()
return cur.rowcount > 0
def set_manga_source(self, manga_url: str, source_id: int) -> None:
"""Меняет источник у манги."""
self.conn.execute(
"UPDATE mangas SET source_id=?, updated_at=? WHERE url=?",
(source_id, _now(), manga_url)
)
self.conn.commit()
def reset_failed_chapters(self, manga_url: str) -> int:
"""Сбрасывает failed и partial главы в pending. Возвращает количество."""
now = _now()
c1 = self.conn.execute(
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? "
"WHERE manga_url=? AND status='failed'",
(now, manga_url)
).rowcount
c2 = self.conn.execute(
"""UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?
WHERE manga_url=? AND status='done'
AND pages_total > 0 AND pages_done < pages_total""",
(now, manga_url)
).rowcount
self.conn.commit()
return c1 + c2
def count_mangas_by_source_domain(self, domain: str) -> int:
"""Считает манги с указанным доменом (для предупреждений в UI)."""
source = self.get_source_by_domain(domain)
if not source:
return 0
return self.conn.execute(
"SELECT COUNT(*) FROM mangas WHERE source_id=?", (source["id"],)
).fetchone()[0]
# ── Mangas ──────────────────────────────────── # ── Mangas ────────────────────────────────────
def add_manga(self, url: str, fmt: str = "cbz") -> bool: def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None,
added_by: Optional[int] = None) -> bool:
"""Добавляет мангу в очередь. Возвращает True если новая.""" """Добавляет мангу в очередь. Возвращает True если новая."""
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,)) cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
if cur.fetchone(): if cur.fetchone():
return False return False
self.conn.execute(""" self.conn.execute("""
INSERT INTO mangas (url, format, status, added_at, updated_at) INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at)
VALUES (?, ?, 'queued', ?, ?) VALUES (?, ?, 'queued', ?, ?, ?, ?)
""", (url, fmt, _now(), _now())) """, (url, fmt, source_id, added_by, _now(), _now()))
self.conn.commit() self.conn.commit()
return True return True
@@ -185,17 +402,21 @@ class StateDB:
self.conn.commit() self.conn.commit()
return count return count
def increment_manga_chapters_done(self, url: str):
# Оставлен для совместимости, но не используется в воркере
pass
def get_manga(self, url: str) -> Optional[dict]: def get_manga(self, url: str) -> Optional[dict]:
cur = self.conn.execute("SELECT * FROM mangas WHERE url=?", (url,)) cur = self.conn.execute("""
SELECT m.*, u.username AS added_by_username
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
WHERE m.url=?
""", (url,))
row = cur.fetchone() row = cur.fetchone()
return dict(row) if row else None return dict(row) if row else None
def get_all_mangas(self) -> list[dict]: def get_all_mangas(self) -> list[dict]:
cur = self.conn.execute("SELECT * FROM mangas ORDER BY added_at DESC") cur = self.conn.execute("""
SELECT m.*, u.username AS added_by_username
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
ORDER BY m.added_at DESC
""")
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def get_manga_format(self, url: str) -> str: def get_manga_format(self, url: str) -> str:
@@ -203,6 +424,57 @@ class StateDB:
row = cur.fetchone() row = cur.fetchone()
return row["format"] if row else "cbz" return row["format"] if row else "cbz"
def get_chapter_stats(self, manga_url: str) -> dict:
"""Returns done/failed/partial chapter counts in a single query."""
row = self.conn.execute("""
SELECT
COUNT(CASE WHEN status='done' THEN 1 END) as done,
COUNT(CASE WHEN status='failed' THEN 1 END) as failed,
COUNT(CASE WHEN status='done' AND pages_total > 0
AND pages_done < pages_total THEN 1 END) as partial
FROM chapters WHERE manga_url=?
""", (manga_url,)).fetchone()
return {"done": row[0], "failed": row[1], "partial": row[2]}
def reset_all_chapters(self, manga_url: str) -> None:
"""Resets ALL chapters to pending (used by force-redownload)."""
self.conn.execute(
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
" WHERE manga_url=?",
(_now(), manga_url)
)
self.conn.commit()
def delete_manga_cascade(self, manga_url: str) -> None:
"""Deletes manga and all related chapters and history."""
self.conn.execute("DELETE FROM chapters WHERE manga_url=?", (manga_url,))
self.conn.execute("DELETE FROM history WHERE manga_url=?", (manga_url,))
self.conn.execute("DELETE FROM mangas WHERE url=?", (manga_url,))
self.conn.commit()
def update_chapter_output_paths(self, manga_url: str, old_prefix: str, new_prefix: str) -> None:
"""Replaces old_prefix with new_prefix in chapter output paths after folder rename."""
chapters = self.get_all_chapters(manga_url)
for ch in chapters:
for col in ("output_cbz", "output_pdf", "output_epub"):
p = ch.get(col)
if p and old_prefix in p:
self.conn.execute(
f"UPDATE chapters SET {col}=?, updated_at=? WHERE chapter_url=?",
(p.replace(old_prefix, new_prefix), _now(), ch["chapter_url"])
)
self.conn.commit()
def get_news(self, limit: int = 100) -> list[dict]:
"""Returns recently downloaded chapters for the news feed."""
cur = self.conn.execute("""
SELECT h.*, m.title as manga_title, m.title_ru
FROM history h LEFT JOIN mangas m ON h.manga_url = m.url
WHERE h.event_type IN ('downloaded', 'auto_downloaded')
ORDER BY h.created_at DESC LIMIT ?
""", (limit,))
return [dict(r) for r in cur.fetchall()]
# ── Chapters ────────────────────────────────── # ── Chapters ──────────────────────────────────
def upsert_chapter(self, manga_url: str, chapter_url: str, def upsert_chapter(self, manga_url: str, chapter_url: str,
@@ -226,6 +498,8 @@ class StateDB:
self.conn.commit() self.conn.commit()
def mark_done(self, chapter_url: str, fmt: str, output_path: str): def mark_done(self, chapter_url: str, fmt: str, output_path: str):
if fmt not in _ALLOWED_FMTS:
raise ValueError(f"Unknown format: {fmt}")
col = f"output_{fmt}" col = f"output_{fmt}"
self.conn.execute(f""" self.conn.execute(f"""
UPDATE chapters SET status='done', {col}=?, updated_at=? UPDATE chapters SET status='done', {col}=?, updated_at=?
@@ -311,10 +585,109 @@ class StateDB:
""") """)
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
# ── Users ─────────────────────────────────────
def create_user(self, username: str, hashed_password: str, role: str = "user",
is_env_admin: bool = False) -> dict:
"""Создаёт пользователя. Возвращает dict без поля password."""
self.conn.execute("""
INSERT INTO users (username, password, role, is_env_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (username, hashed_password, role, 1 if is_env_admin else 0, _now(), _now()))
self.conn.commit()
row = self.conn.execute(
"SELECT id, username, role, is_env_admin, created_at FROM users WHERE username=?",
(username,)
).fetchone()
return dict(row)
def get_user_by_id(self, user_id: int) -> Optional[dict]:
row = self.conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return dict(row) if row else None
def get_user_by_username(self, username: str) -> Optional[dict]:
row = self.conn.execute(
"SELECT * FROM users WHERE username=?", (username,)
).fetchone()
return dict(row) if row else None
def get_all_users(self) -> list[dict]:
"""Возвращает всех пользователей без поля password."""
cur = self.conn.execute(
"SELECT id, username, role, is_env_admin, created_at, updated_at FROM users ORDER BY id"
)
return [dict(r) for r in cur.fetchall()]
def count_admins(self) -> int:
return self.conn.execute(
"SELECT COUNT(*) FROM users WHERE role='admin'"
).fetchone()[0]
def update_user(self, user_id: int, **kwargs) -> None:
"""Обновляет поля пользователя. Разрешённые поля: username, password, role."""
allowed = {"username", "password", "role"}
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
if not updates:
return
updates["updated_at"] = _now()
sets = ", ".join(f"{k}=?" for k in updates)
self.conn.execute(
f"UPDATE users SET {sets} WHERE id=?", [*updates.values(), user_id]
)
self.conn.commit()
def delete_user(self, user_id: int) -> None:
"""Удаляет пользователя и все его сессии."""
self.conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
self.conn.execute("DELETE FROM users WHERE id=?", (user_id,))
self.conn.commit()
# ── Sessions ──────────────────────────────────
def create_session(self, token: str, user_id: int, expires_at: str) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)",
(token, user_id, _now(), expires_at)
)
self.conn.commit()
def get_session(self, token: str) -> Optional[dict]:
"""Возвращает сессию если действующая (не истекла)."""
row = self.conn.execute(
"SELECT * FROM sessions WHERE token=? AND expires_at > ?",
(token, _now())
).fetchone()
return dict(row) if row else None
def delete_session(self, token: str) -> None:
self.conn.execute("DELETE FROM sessions WHERE token=?", (token,))
self.conn.commit()
def cleanup_expired_sessions(self) -> int:
"""Удаляет истёкшие сессии. Возвращает количество удалённых."""
cur = self.conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),))
self.conn.commit()
return cur.rowcount
def close(self): def close(self):
self.conn.close() self.conn.close()
def _now() -> str: _ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
return datetime.utcnow().isoformat()
def _now() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _extract_domain(url: str) -> str:
"""Извлекает домен без www."""
try:
domain = urlparse(url).netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""

15
src/utils.py Normal file
View File

@@ -0,0 +1,15 @@
"""
Общие утилиты, используемые в нескольких модулях.
"""
import re
from .sources.base import Chapter
def safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def safe_chapter_name(ch: Chapter) -> str:
vol = f"v{ch.volume:02d}_" if ch.volume else ""
return f"{vol}ch{ch.number:06.1f}"

View File

@@ -3,7 +3,6 @@
""" """
import asyncio import asyncio
import os import os
import re
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
@@ -11,9 +10,11 @@ from typing import Callable, Optional
from loguru import logger from loguru import logger
from .browser import BrowserManager from .browser import BrowserManager
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter from .sources import registry, get_source_for_url, extract_domain
from .sources.base import Chapter, MangaInfo
from .exporter import export, MangaMeta from .exporter import export, MangaMeta
from .state import StateDB from .state import StateDB
from .utils import safe_name, safe_chapter_name
OUTPUT_DIR = Path("/app/output") OUTPUT_DIR = Path("/app/output")
@@ -21,15 +22,6 @@ OUTPUT_DIR = Path("/app/output")
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3")) CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
def _safe_name(s: str) -> str:
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
def _safe_chapter_name(ch: Chapter) -> str:
vol = f"v{ch.volume:02d}_" if ch.volume else ""
return f"{vol}ch{ch.number:06.1f}"
async def download_manga( async def download_manga(
url: str, url: str,
fmt: str = "cbz", fmt: str = "cbz",
@@ -61,10 +53,23 @@ async def download_manga(
started_ts = await db_call(db.mark_started, url) started_ts = await db_call(db.mark_started, url)
await emit({"type": "manga_start", "url": url, "started_at": started_ts}) await emit({"type": "manga_start", "url": url, "started_at": started_ts})
# Резолвим источник
source = get_source_for_url(url, db)
if source is None:
# Последний шанс: по source_id в БД
manga_row = await db_call(db.get_manga, url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
await db_call(db.update_manga_status, url, "failed")
await emit({"type": "source_unknown", "url": url,
"error": "Источник не определён. Выберите источник в настройках манги."})
return
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
ctx, info_page = await bm.new_page() ctx, info_page = await bm.new_page()
manga = await get_manga_info(info_page, url) manga = await source.get_manga_info(info_page, url)
await info_page.close() await info_page.close()
if not manga: if not manga:
@@ -96,7 +101,7 @@ async def download_manga(
_db_manga = await db_call(db.get_manga, url) _db_manga = await db_call(db.get_manga, url)
folder_name = ( folder_name = (
(_db_manga.get("folder_name") if _db_manga else None) (_db_manga.get("folder_name") if _db_manga else None)
or _safe_name(manga.title_ru or manga.title) or safe_name(manga.title_ru or manga.title)
) )
manga_dir = output_dir / folder_name manga_dir = output_dir / folder_name
manga_dir.mkdir(parents=True, exist_ok=True) manga_dir.mkdir(parents=True, exist_ok=True)
@@ -178,22 +183,23 @@ async def download_manga(
try: try:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
pages_done_count = [0] pages_done = 0
async def on_page(page_idx: int, pages_total: int): async def on_page(page_idx: int, pages_total: int):
pages_done_count[0] += 1 nonlocal pages_done
pages_done += 1
await db_call(db.update_chapter_pages, await db_call(db.update_chapter_pages,
ch.url, pages_total, pages_done_count[0]) ch.url, pages_total, pages_done)
await emit({ await emit({
"type": "page_done", "type": "page_done",
"url": url, "url": url,
"chapter_url": ch.url, "chapter_url": ch.url,
"page_idx": page_idx, "page_idx": page_idx,
"pages_done": pages_done_count[0], "pages_done": pages_done,
"pages_total": pages_total, "pages_total": pages_total,
}) })
image_paths = await get_chapter_images_and_download( image_paths = await source.get_chapter_images_and_download(
ch_page, ch.url, ch_page, ch.url,
dest_dir=tmp_path, dest_dir=tmp_path,
manga_url=url, manga_url=url,
@@ -211,7 +217,7 @@ async def download_manga(
"chapter_url": ch.url}) "chapter_url": ch.url})
return return
ch_name = _safe_chapter_name(ch) ch_name = safe_chapter_name(ch)
ch_meta = MangaMeta( ch_meta = MangaMeta(
series=manga.title_ru or manga.title, series=manga.title_ru or manga.title,
series_full=manga.title_full or "", series_full=manga.title_full or "",
@@ -298,6 +304,8 @@ async def download_manga(
await ctx.close() await ctx.close()
except asyncio.CancelledError: except asyncio.CancelledError:
# Прокидываем выше — обработка статуса уже сделана API-эндпоинтом
# (или воркером в _queue_worker_loop).
raise raise
except Exception as e: except Exception as e:
logger.error("Manga worker error {}: {}", url, e) logger.error("Manga worker error {}: {}", url, e)
@@ -329,9 +337,19 @@ async def check_for_updates(
db.add_history(manga_url=url, event_type="check_started") db.add_history(manga_url=url, event_type="check_started")
await emit({"type": "check_started", "url": url}) await emit({"type": "check_started", "url": url})
# Резолвим источник
source = get_source_for_url(url, db)
if source is None:
manga_row = db.get_manga(url)
if manga_row and manga_row.get("source_id"):
source = registry.get_by_db_id(manga_row["source_id"], db)
if source is None:
await emit({"type": "source_unknown", "url": url})
return []
async with BrowserManager(headless=True) as bm: async with BrowserManager(headless=True) as bm:
_, page = await bm.new_page() _, page = await bm.new_page()
manga = await get_manga_info(page, url) manga = await source.get_manga_info(page, url)
await page.close() await page.close()
if not manga: if not manga:
return [] return []