Compare commits

..

15 Commits

Author SHA1 Message Date
672e199d3a validation 2026-05-03 14:37:57 +03:00
0f8707fe93 retry 2026-05-03 14:12:25 +03:00
84b24b2b5b upd 2026-05-03 14:07:18 +03:00
bb6f2d67d8 upd 2026-05-03 13:37:21 +03:00
2cb244d973 upd 2026-05-03 13:12:55 +03:00
07bc7ef1e0 mangalib 2026-05-02 22:31:33 +03:00
a7eaa22646 mangalib 2026-05-02 21:59:59 +03:00
419614d295 upd 2026-05-02 20:15:36 +03:00
fcd1dfb74c upd 2026-05-02 20:03:21 +03:00
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 4820 additions and 1437 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
snapshot event ──────────► state.mangas = enriched list connectWS() ─────────────────WS /ws
+ live events ──────────► state.mangas[url].* обновляется
└─ snapshot event ───────► state.mangas = enriched list
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список │ + live events ───────► state.mangas[url].* обновляется
(перезаписывает snapshot если пришёл раньше)
└─ fetch('/api/mangas') ──────────► state.mangas = полный список
``` ```
**Важный нюанс порядка:** `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` восстанавливает незавершённые задачи из БД в очередь.

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

1097
src/api.py

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

@@ -26,6 +26,7 @@ class MangaMeta:
language: str = "ru" language: str = "ru"
summary: str = "" # Описание/синопсис серии summary: str = "" # Описание/синопсис серии
genre: str = "" # Жанры через запятую (для ComicInfo Genre) genre: str = "" # Жанры через запятую (для ComicInfo Genre)
tags: str = "" # Теги через запятую (для ComicInfo Tags)
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup) series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
@@ -89,6 +90,7 @@ def _make_comic_info(meta: MangaMeta) -> str:
add("Count", meta.chapters_total) add("Count", meta.chapters_total)
add("Genre", meta.genre) add("Genre", meta.genre)
add("Tags", meta.tags)
add("LanguageISO", meta.language) add("LanguageISO", meta.language)
# Manga = YesAndRightToLeft — стандартная японская манга # Manga = YesAndRightToLeft — стандартная японская манга
@@ -131,8 +133,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]
if pil_images: try:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF") 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()
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())]

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

@@ -0,0 +1,76 @@
"""
Реестр источников манги.
Для добавления нового источника:
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
from .mangalib import MangalibSource
# ── Регистрация источников ─────────────────────
# Добавьте новые источники сюда:
SOURCES: list = [
ReadmangaSource(),
MangalibSource(),
]
# Быстрый поиск по 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 ""

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

@@ -0,0 +1,67 @@
"""
Базовые модели данных и Protocol-интерфейс для источников манги.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Protocol, runtime_checkable
from playwright.async_api import Page
class AuthRequiredError(Exception):
"""Источник требует авторизации — токен не задан или просрочен."""
def __init__(self, source_slug: str):
self.source_slug = source_slug
super().__init__(f"Auth required for source: {source_slug}")
# ──────────────────────────────────────────────
# Модели данных (общие для всех источников)
# ──────────────────────────────────────────────
@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)
tags: list[str] = field(default_factory=list)
cover_url: str = ""
# ──────────────────────────────────────────────
# Интерфейс источника
# ──────────────────────────────────────────────
@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 и возвращает список путей."""
...

845
src/sources/mangalib.py Normal file
View File

@@ -0,0 +1,845 @@
"""
Адаптер MangaLib: поддерживает mangalib.me и его зеркала.
Принцип работы:
- Список глав: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapters
Возвращает все главы сразу (не требует пагинации).
URL главы: {origin}/ru/{manga_slug}/read/v{vol}/c{num}
- Страница главы: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapter?...
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
"""
import asyncio
import json as _json
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, AuthRequiredError
class MangalibSource:
slug = "mangalib"
display_name = "MangaLib"
supports_auth_token = True
# CDN-домены для изображений глав (актуальные)
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
# Токен авторизации — устанавливается воркером из настроек источника в БД
auth_token: Optional[str] = None
# ──────────────────────────────────────────────
# Страница манги — список глав
# ──────────────────────────────────────────────
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
"""Открывает страницу манги и возвращает список всех глав."""
logger.info("Загружаем страницу манги MangaLib: {}", url)
chapters_url = _ensure_chapters_section(url)
base_manga_url = url.split("?")[0].rstrip("/")
# Слушаем API-ответы до навигации
chapters_api_data: list = []
manga_api_data: dict = {}
chapters_auth_error: list = []
lock = asyncio.Lock()
async def on_response(resp):
resp_url = resp.url
if "api.cdnlibs.org" not in resp_url:
return
try:
# api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров)
if re.search(r"/chapters$", resp_url):
if resp.status in (401, 403):
chapters_auth_error.append(True)
return
body = await resp.body()
data = _json.loads(body)
raw = data.get("data", [])
if isinstance(raw, list) and raw:
async with lock:
if not chapters_api_data:
chapters_api_data.extend(raw)
logger.debug("Chapters API: {} глав получено", len(raw))
# api.cdnlibs.org/api/manga/{slug}?fields[]=...
elif re.search(r"/manga/[^/]+$", resp_url.split("?")[0]) and "fields" in resp_url:
body = await resp.body()
data = _json.loads(body)
raw = data.get("data", {})
if isinstance(raw, dict) and raw:
async with lock:
if not manga_api_data:
manga_api_data.update(raw)
except Exception as e:
logger.debug("API parse error: {}", e)
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
ok = await _navigate(page, chapters_url)
if not ok:
mirror_chapters_url = _switch_to_mirror(chapters_url)
if mirror_chapters_url != chapters_url:
logger.info("Основной домен недоступен, пробуем зеркало: {}", mirror_chapters_url)
ok = await _navigate(page, mirror_chapters_url)
if ok:
chapters_url = mirror_chapters_url
base_manga_url = _switch_to_mirror(base_manga_url)
if not ok:
page.remove_listener("response", on_response)
return None
# Ждём API-ответов (обычно приходят за 1-3 секунды)
for _ in range(30):
async with lock:
if chapters_api_data:
break
await asyncio.sleep(0.3)
page.remove_listener("response", on_response)
if chapters_auth_error and not chapters_api_data:
raise AuthRequiredError(self.slug)
# Извлекаем pub_status из API манги (надёжнее DOM)
async with lock:
manga_meta = dict(manga_api_data)
pub_status = _pub_status_from_api(manga_meta)
if pub_status == "unknown":
pub_status = await _extract_pub_status(page)
# Предпочитаем имена из API (надёжнее DOM и page.title)
async with lock:
manga_meta_snap = dict(manga_api_data)
title_ru = (manga_meta_snap.get("rus_name") or "").strip()
title_name = (manga_meta_snap.get("name") or "").strip()
if not title_ru:
title_ru = await _extract_title(page)
title_full = (f"{title_ru} / {title_name}" if title_name and title_ru and title_name != title_ru
else title_ru or title_name)
if not title_full:
try:
page_title = await page.title()
page_title = re.sub(r"\s*([-|•]|читать|онлайн).*$", "", page_title, flags=re.IGNORECASE).strip()
title_full = page_title
except Exception:
pass
if not title_ru:
title_ru = title_full
logger.info("Манга: {} | ru: {}", title_full, title_ru)
logger.info("Статус выпуска: {}", pub_status)
description = await _extract_description(page)
genres = await _extract_genres(page)
# Получаем обложку, описание и теги из API
async with lock:
manga_meta_for_extras = dict(manga_api_data)
cover_url, extra_description, tags = await _fetch_extra_meta(
page, manga_meta_for_extras, url, self.auth_token
)
if extra_description:
description = extra_description
if not description:
description = await _extract_description(page)
async with lock:
raw_chapters = list(chapters_api_data)
if raw_chapters:
chapters = _chapters_from_api(raw_chapters, base_manga_url)
else:
logger.warning("Chapters API не ответил, используем DOM-fallback")
chapters = await _chapters_from_dom(page, base_manga_url)
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,
tags=tags,
cover_url=cover_url,
)
# ──────────────────────────────────────────────
# Скачивание главы
# ──────────────────────────────────────────────
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. Пассивно наблюдает ответы через page.on("response"):
- api.cdnlibs.org/chapter? → список страниц
- api.cdnlibs.org/imageServers → серверы CDN
3. Скачивает все страницы через page.context.request.get()
(разделяет cookies с браузером, без CORS-ограничений).
"""
t_start = time.monotonic()
ch_id = chapter_url.rstrip("/").split("/")[-1]
logger.info("[{}] Загружаем главу MangaLib: {}", ch_id, chapter_url)
dest_dir.mkdir(parents=True, exist_ok=True)
referer_origin = _base_url(manga_url or chapter_url)
chapter_api: dict = {}
image_servers: list = []
chapter_auth_error: list = []
lock = asyncio.Lock()
async def on_response(resp):
resp_url = resp.url
try:
if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url:
if resp.status in (401, 403):
chapter_auth_error.append(True)
return
body = await resp.body()
data = _json.loads(body)
async with lock:
if not chapter_api.get("pages"):
chapter_api.update(data.get("data", {}))
elif "api.cdnlibs.org" in resp_url and "imageServers" in resp_url:
body = await resp.body()
data = _json.loads(body)
servers = data.get("data", {}).get("imageServers", [])
async with lock:
if not image_servers:
image_servers.extend(s["url"] for s in servers if "url" in s)
except Exception:
pass
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
referer = manga_url or referer_origin
ok = await _navigate(page, chapter_url, referer=referer)
if not ok:
mirror_chapter_url = _switch_to_mirror(chapter_url)
if mirror_chapter_url != chapter_url:
logger.info("[{}] Основной домен недоступен, пробуем зеркало: {}", ch_id, mirror_chapter_url)
mirror_referer = _switch_to_mirror(referer) if referer else referer
ok = await _navigate(page, mirror_chapter_url, referer=mirror_referer)
if ok:
chapter_url = mirror_chapter_url
referer_origin = _base_url(mirror_chapter_url)
if not ok:
page.remove_listener("response", on_response)
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
return []
# Ждём ответ chapter API (обычно приходит за 1-3 секунды)
for _ in range(40):
async with lock:
if chapter_api.get("pages"):
break
await asyncio.sleep(0.5)
page.remove_listener("response", on_response)
if chapter_auth_error and not chapter_api.get("pages"):
raise AuthRequiredError(self.slug)
async with lock:
pages_info = list(chapter_api.get("pages", []))
servers_list = list(image_servers)
if not pages_info:
try:
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
except Exception:
page_info = "?"
logger.error("[{}] API не вернул данные страниц. Страница: {}", ch_id, page_info)
return []
total = len(pages_info)
logger.info("[{}] Страниц по API: {}", ch_id, total)
# Строим маппинг: filename → 0-based index (slug 1-based)
fname_to_idx: dict[str, int] = {}
page_url_by_idx: dict[int, str] = {}
for p in pages_info:
try:
idx = int(p.get("slug", 0)) - 1
if idx < 0:
continue
fname = p.get("image", "")
url_part = p.get("url", "")
if fname:
fname_to_idx[fname] = idx
if url_part:
page_url_by_idx[idx] = url_part
url_fname = url_part.rstrip("/").split("/")[-1]
if url_fname and url_fname not in fname_to_idx:
fname_to_idx[url_fname] = idx
except Exception:
pass
# Определяем CDN сервер из img src или constants API
server = await _detect_server(page, servers_list)
logger.info("[{}] CDN сервер: {}", ch_id, server)
alt_servers = [s for s in servers_list if s != server]
# Скачиваем все страницы через Playwright APIRequestContext
captured: dict[str, bytes] = {}
failed_idxs: list[int] = []
all_servers = [server] + alt_servers
logger.info("[{}] Скачиваем {} страниц...", ch_id, total)
for idx in range(total):
url_part = page_url_by_idx.get(idx, "")
if not url_part:
continue
fname = url_part.rstrip("/").split("/")[-1]
body = None
for srv in all_servers:
body = await _api_fetch(page, srv + url_part, referer_origin)
if body:
if srv != server:
logger.debug("[{}] alt сервер OK: {} ({})", ch_id, fname, srv)
break
if body:
captured[fname] = body
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
failed_idxs.append(idx)
# Retry провалившихся страниц с задержкой
if failed_idxs:
logger.info("[{}] Retry {} страниц с задержкой...", ch_id, len(failed_idxs))
await asyncio.sleep(2)
for idx in failed_idxs:
url_part = page_url_by_idx.get(idx, "")
if not url_part:
continue
fname = url_part.rstrip("/").split("/")[-1]
body = None
for srv in all_servers:
body = await _api_fetch(page, srv + url_part, referer_origin)
if body:
break
if body:
captured[fname] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
if on_page:
try:
asyncio.ensure_future(on_page(0, 0))
except Exception:
pass
else:
logger.warning("[{}] Не удалось скачать: {}", ch_id, fname)
elapsed = time.monotonic() - t_start
matched = sum(1 for f in captured if f in fname_to_idx)
logger.info("[{}] Скачано: {}/{} за {:.1f}с", ch_id, matched, total, elapsed)
# Сохраняем файлы
paths: dict[int, Path] = {}
for fname, body in captured.items():
idx = fname_to_idx.get(fname)
if idx is None:
continue
ext = _get_ext(fname)
p = dest_dir / f"{idx:04d}{ext}"
p.write_bytes(body)
paths[idx] = p
missing_idxs = [i for i in range(total) if i not in paths]
if missing_idxs:
logger.warning("[{}] Пропущено {}/{} стр. №: {}",
ch_id, len(missing_idxs), total, [i + 1 for i in missing_idxs])
return [paths[i] for i in sorted(paths.keys())]
async def get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц через API без скачивания изображений."""
pages_info: list = []
auth_err: list = []
lock = asyncio.Lock()
async def on_response(resp):
try:
if "api.cdnlibs.org" in resp.url and "/chapter?" in resp.url:
if resp.status in (401, 403):
auth_err.append(True)
return
body = await resp.body()
data = _json.loads(body)
async with lock:
if not pages_info:
pages_info.extend(data.get("data", {}).get("pages", []))
except Exception:
pass
if self.auth_token:
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
page.on("response", on_response)
referer = manga_url or _base_url(chapter_url)
ok = await _navigate(page, chapter_url, referer=referer)
if not ok:
mirror_url = _switch_to_mirror(chapter_url)
if mirror_url != chapter_url:
ok = await _navigate(
page, mirror_url,
referer=_switch_to_mirror(referer) if referer else referer,
)
if not ok:
page.remove_listener("response", on_response)
return 0
for _ in range(40):
async with lock:
if pages_info or auth_err:
break
await asyncio.sleep(0.5)
page.remove_listener("response", on_response)
if auth_err and not pages_info:
raise AuthRequiredError(self.slug)
return len(pages_info)
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)
# ──────────────────────────────────────────────
# Зеркальные домены: при недоступности основного переключаемся на зеркало
_MIRROR_MAP = {
"mangalib.me": "mangalib.org",
"mangalib.org": "mangalib.me",
"hentailib.me": "mangalib.org",
"yaoilib.me": "mangalib.org",
"readlib.net": "mangalib.org",
}
def _switch_to_mirror(url: str) -> str:
"""Заменяет домен в URL на зеркало из _MIRROR_MAP. Возвращает исходный URL если зеркала нет."""
parsed = urlparse(url)
host = parsed.netloc.lower().removeprefix("www.")
mirror = _MIRROR_MAP.get(host)
if not mirror:
return url
return parsed._replace(netloc=mirror).geturl()
def _ensure_chapters_section(url: str) -> str:
if "section=chapters" in url:
return url
sep = "&" if "?" in url else "?"
return url + sep + "section=chapters"
def _manga_slug_from_url(url: str) -> str:
"""Извлекает slug манги из URL страницы или главы.
Примеры входных URL:
https://mangalib.me/ru/manga/11312--subete... → 11312--subete...
https://mangalib.me/ru/11312--subete.../read/v1/c1 → 11312--subete...
"""
parsed = urlparse(url)
parts = [p for p in parsed.path.split("/") if p]
# Убираем языковой префикс ('ru', 'en', ...)
if parts and len(parts[0]) <= 3 and parts[0].isalpha():
parts = parts[1:]
# Убираем 'manga' если есть
if parts and parts[0] == "manga":
parts = parts[1:]
return parts[0] if parts else ""
def _chapters_from_api(raw: list, manga_url: str) -> list[Chapter]:
"""Строит список глав из ответа api.cdnlibs.org/chapters."""
parsed = urlparse(manga_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
slug = _manga_slug_from_url(manga_url)
# Определяем языковой префикс из оригинального URL (/ru/, /en/, ...)
path_parts = [p for p in parsed.path.split("/") if p]
lang_prefix = path_parts[0] if path_parts and len(path_parts[0]) <= 3 else "ru"
chapters = []
for ch in raw:
try:
vol = str(ch.get("volume") or "1")
num = str(ch.get("number") or "0")
name = ch.get("name") or ""
try:
number_f = float(num)
except Exception:
number_f = 0.0
try:
vol_i = int(float(vol))
except Exception:
vol_i = 0
ch_url = f"{origin}/{lang_prefix}/{slug}/read/v{vol}/c{num}"
title = f"Том {vol}, Глава {num}"
if name:
title += f" - {name}"
chapters.append(Chapter(title=title, url=ch_url, number=number_f, volume=vol_i))
except Exception as e:
logger.debug("Пропуск главы из API: {}", e)
chapters.sort(key=lambda c: (c.volume, c.number))
return chapters
async def _chapters_from_dom(page: Page, manga_url: str) -> list[Chapter]:
"""Fallback: извлекает главы из DOM-ссылок вида /read/v{vol}/c{num}."""
try:
raw = await page.evaluate("""
() => {
const links = Array.from(document.querySelectorAll('a[href*="/read/v"]'));
const result = [];
const seen = new Set();
for (const a of links) {
const href = a.href;
if (!href || seen.has(href)) continue;
if (!/\\/read\\/v\\d/.test(href)) continue;
const text = a.textContent.trim();
// Пропускаем ссылки без нормального текста (кнопки навигации и т.п.)
if (!text || /^\\d+\\s*\\/\\s*\\d+$/.test(text)) continue;
seen.add(href);
result.push({ href, text });
}
return result;
}
""")
if not raw:
return []
chapters = []
for item in raw:
href = item["href"]
m = re.search(r"/read/v(\d+(?:\.\d+)?)/c(\d+(?:\.\d+)?)", href)
if not m:
continue
vol_s, num_s = m.group(1), m.group(2)
try:
number_f = float(num_s)
vol_i = int(float(vol_s))
except Exception:
continue
text = item["text"] or f"Том {vol_s}, Глава {num_s}"
chapters.append(Chapter(title=text, url=href, number=number_f, volume=vol_i))
chapters.sort(key=lambda c: (c.volume, c.number))
return chapters
except Exception as e:
logger.debug("_chapters_from_dom: {}", e)
return []
def _pub_status_from_api(manga_meta: dict) -> str:
"""Извлекает статус публикации из ответа API манги."""
status = manga_meta.get("status", {})
if isinstance(status, dict):
label = (status.get("label") or "").lower()
if "завершён" in label or "завершен" in label or "complete" in label:
return "completed"
if "продолжает" in label or "ongoing" in label or "выпускает" in label:
return "ongoing"
return "unknown"
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=15_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_title(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga) {
const m = window.__DATA__.manga;
return m.rus_name || m.name || '';
}
const selectors = [
'.media-name__main',
'.manga-name h1',
'h1.media-title',
'h1.page-title',
'h1',
];
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 ""
async def _extract_pub_status(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.status) {
const s = window.__DATA__.manga.status;
const label = (s.label || s.name || '').toLowerCase();
if (label.includes('завершён') || label.includes('завершен') || label.includes('complete')) return 'completed';
if (label.includes('продолжает') || label.includes('ongoing') || label.includes('выпускает')) return 'ongoing';
}
const selectors = [
'.media-info-item__status',
'.status-value',
'[class*="status"] .value',
'[class*="status"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (!el) continue;
const t = el.textContent.toLowerCase();
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
}
return 'unknown';
}
""")
return result or "unknown"
except Exception:
return "unknown"
async def _extract_description(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.summary) {
return window.__DATA__.manga.summary;
}
const selectors = [
'.media-description__text',
'.description-text',
'.manga-description',
'[class*="description"] p',
];
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("""
() => {
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.genres) {
return window.__DATA__.manga.genres.map(g => g.name || g.label || '').filter(Boolean);
}
const selectors = [
'.genre-list a',
'.media-tags a',
'.tags a',
'[class*="genre"] 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 []
def _parse_summary_doc(doc) -> str:
"""Конвертирует ProseMirror JSON-документ в plain text."""
if not doc or not isinstance(doc, dict):
return ""
if doc.get("type") == "text":
return doc.get("text", "")
parts = []
for node in doc.get("content", []):
text = _parse_summary_doc(node)
if text:
parts.append(text)
return " ".join(parts)
async def _fetch_extra_meta(
page: Page,
manga_api_data: dict,
manga_url: str,
auth_token: str | None,
) -> tuple[str, str, list[str]]:
"""
Возвращает (cover_url, description, tags) из уже полученных данных API или,
если нужных полей нет, делает явный supplementary-запрос к API.
"""
def _extract_from_data(data: dict) -> tuple[str, str, list[str]]:
cover_url = ""
cover_obj = data.get("cover")
if isinstance(cover_obj, dict):
cover_url = cover_obj.get("default") or cover_obj.get("thumbnail") or ""
description = ""
summary = data.get("summary")
if summary:
if isinstance(summary, dict):
description = _parse_summary_doc(summary).strip()
elif isinstance(summary, str):
description = summary.strip()
tags: list[str] = []
for t in data.get("tags") or []:
name = (t.get("name") or t.get("label") or "").strip()
if name:
tags.append(name)
return cover_url, description, tags
cover_url, description, tags = _extract_from_data(manga_api_data)
# Если хотя бы одного поля нет — делаем явный supplementary-запрос
if not cover_url or not description or not tags:
slug = _manga_slug_from_url(manga_url)
referer = _base_url(manga_url) + "/"
api_url = (
f"https://api.cdnlibs.org/api/manga/{slug}"
"?fields[]=summary&fields[]=tags&fields[]=cover"
)
try:
headers: dict = {"Referer": referer, "Accept": "application/json"}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
resp = await page.context.request.get(api_url, headers=headers)
if resp.ok:
body = await resp.body()
data = _json.loads(body).get("data", {})
extra_cover, extra_desc, extra_tags = _extract_from_data(data)
if not cover_url:
cover_url = extra_cover
if not description:
description = extra_desc
if not tags:
tags = extra_tags
logger.debug("Supplementary API: cover={}, desc_len={}, tags={}",
bool(cover_url), len(description), len(tags))
except Exception as e:
logger.debug("Supplementary API error: {}", e)
return cover_url, description, tags
async def _detect_server(page: Page, servers_list: list[str]) -> str:
"""Определяет CDN-сервер из img src на странице или из constants API."""
try:
imgs = await page.evaluate("""() =>
Array.from(document.querySelectorAll('img')).map(i => i.src)
.filter(s => s && /https?:\\/\\//.test(s) && /\\.(png|jpg|webp)/i.test(s))
""")
for img_src in imgs:
m = re.match(r"(https?://[^/]+)", img_src)
if m:
srv = m.group(1)
if any(pat in srv for pat in ["mixlib", "imglib", "imgslib"]):
return srv
except Exception:
pass
if servers_list:
return servers_list[0]
return "https://img3.mixlib.me"
async def _api_fetch(page: Page, url: str, referer: str = "") -> bytes | None:
"""
Скачивает изображение через Playwright APIRequestContext.
Разделяет cookies с браузерным контекстом, не ограничен CORS.
"""
try:
headers: dict = {"Accept": "image/png,image/jpeg,image/webp,image/*,*/*"}
if referer:
headers["Referer"] = referer
response = await page.context.request.get(url, headers=headers)
if response.ok:
body = await response.body()
return body if len(body) > 500 else None
except Exception:
pass
return None
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://mangalib.me"

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

@@ -0,0 +1,695 @@
"""
Адаптер 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)
tags = await _extract_tags(page)
cover_url = await _get_cover_url(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,
tags=tags,
cover_url=cover_url,
)
# ──────────────────────────────────────────────
# Скачивание главы
# ──────────────────────────────────────────────
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()
# Имена файлов из readerInit — заполняются после парсинга страницы.
# Позволяет перехватывать картинки с незнакомых CDN-доменов (например, при VPN).
expected_filenames: set[str] = set()
async def route_handler(route, request):
url = request.url
base = _base(url)
fname = base.split("/")[-1]
if not _is_manga_image(url):
# Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit —
# значит CDN сменился (VPN, балансировка). Перехватываем.
if not expected_filenames or fname not in expected_filenames:
await route.continue_()
return
logger.debug("[{}] CDN fallback: {} (unknown domain: {})",
ch_id, fname, url.split("/")[2])
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)
# Активируем CDN-fallback в route_handler: теперь он знает ожидаемые имена файлов
expected_filenames.update(filename_to_idx.keys())
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)
async def _js_fetch(url: str) -> bytes | None:
"""Скачивает изображение через JS fetch в контексте браузера."""
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; }
}""", url)
if data_b64:
body = base64.b64decode(data_b64)
return body if len(body) > 500 else None
except Exception:
pass
return None
# Retry 1: 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:
async with lock:
if retry_base in captured:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock:
captured[retry_base] = body
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
# Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка).
# Браузер их загрузил, но route_handler не захватил байты.
# Берём URL напрямую из readerInit и достаём через JS fetch.
async with lock:
captured_fnames = {b.split("/")[-1] for b in captured}
unperceived = [
_base(u) for u in image_urls
if _base(u).split("/")[-1] not in captured_fnames
and _base(u) not in route_errors
and _base(u) not in route_statuses
]
if unperceived:
logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..",
ch_id, len(unperceived))
for retry_base in unperceived:
async with lock:
if retry_base.split("/")[-1] in captured_fnames:
continue
fname = retry_base.split("/")[-1]
body = await _js_fetch(retry_base)
if body:
async with lock:
captured[retry_base] = body
captured_fnames.add(fname)
logger.info("[{}] CDN retry OK: {} ({} байт)", ch_id, fname, len(body))
else:
logger.warning("[{}] CDN retry null для '{}'", ch_id, fname)
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 get_chapter_page_count(
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
) -> int:
"""Открывает главу и возвращает количество страниц без скачивания изображений."""
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
ok = await _navigate(page, load_url)
if not ok:
return 0
urls = await _extract_images_from_js(page)
if not urls:
urls = await _extract_images_from_dom(page)
return len(urls)
# ──────────────────────────────────────────────
# Вспомогательные функции (приватные)
# ──────────────────────────────────────────────
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("""
() => {
// Приоритетный селектор — новый сайт ReadManga
const crDesc = document.querySelector('.cr-description__content');
if (crDesc) {
const parts = [];
crDesc.querySelectorAll('p, span, div').forEach(el => {
const t = el.textContent.trim();
if (t) parts.push(t);
});
if (parts.length) return parts.join(' ');
const t = crDesc.textContent.trim();
if (t) return t;
}
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_tags(page: Page) -> list[str]:
try:
result = await page.evaluate("""
() => {
const crTags = document.querySelector('.cr-tags');
if (crTags) {
const els = crTags.querySelectorAll('a, span, li');
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
const t = crTags.textContent.trim();
if (t) return t.split(/[,;]/).map(s => s.trim()).filter(Boolean);
}
return [];
}
""")
return result or []
except Exception:
return []
async def _get_cover_url(page: Page) -> str:
try:
result = await page.evaluate("""
() => {
const wrapper = document.querySelector('.cr-hero-poster-wrapper');
if (wrapper) {
const img = wrapper.querySelector('img');
if (img) return img.src || img.dataset.src || '';
}
return '';
}
""")
return (result or "").strip()
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,51 @@
""" """
Хранение состояния скачивания в 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",
]
# Домены MangaLib по умолчанию (сидинг при первом запуске)
_DEFAULT_MANGALIB_DOMAINS = [
"mangalib.me",
"mangalib.org",
"hentailib.me",
"yaoilib.me",
"readlib.net",
]
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
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 ""
class StateDB: class StateDB:
def __init__(self, db_path: Path = DB_PATH): def __init__(self, db_path: Path = DB_PATH):
@@ -35,7 +72,11 @@ class StateDB:
added_at TEXT, added_at TEXT,
updated_at TEXT, updated_at TEXT,
started_at TEXT, started_at TEXT,
finished_at TEXT finished_at TEXT,
folder_name TEXT,
source_id INTEGER REFERENCES sources(id),
added_by INTEGER REFERENCES users(id),
last_error TEXT
) )
""") """)
self.conn.execute(""" self.conn.execute("""
@@ -68,18 +109,60 @@ 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"),
("chapters", "pages_done", "INTEGER DEFAULT 0"), ("chapters", "pages_done", "INTEGER DEFAULT 0"),
("mangas", "title_ru", "TEXT"), ("mangas", "title_ru", "TEXT"),
("mangas", "title_full", "TEXT"), ("mangas", "title_full", "TEXT"),
("mangas", "pub_status", "TEXT DEFAULT 'unknown'"), ("mangas", "pub_status", "TEXT DEFAULT 'unknown'"),
("mangas", "auto_update", "INTEGER DEFAULT 0"), ("mangas", "auto_update", "INTEGER DEFAULT 0"),
("mangas", "last_checked_at", "TEXT"), ("mangas", "last_checked_at","TEXT"),
("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)"),
("mangas", "last_error", "TEXT"),
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
("mangas", "description", "TEXT"),
("mangas", "tags", "TEXT"),
("mangas", "cover_url", "TEXT"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -88,27 +171,218 @@ 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))
# Сидинг доменов MangaLib при первом запуске
ml = self.conn.execute("SELECT id FROM sources WHERE slug='mangalib'").fetchone()
if ml:
count = self.conn.execute(
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (ml["id"],)
).fetchone()[0]
if count == 0:
for domain in _DEFAULT_MANGALIB_DOMAINS:
try:
self.conn.execute(
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
(ml["id"], domain)
)
except Exception:
pass
self.conn.commit()
logger.info("Сидинг доменов MangaLib: {} доменов", len(_DEFAULT_MANGALIB_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
def update_manga_info(self, url: str, title: str, chapters_total: int, def update_manga_info(self, url: str, title: str, chapters_total: int,
title_ru: str = "", title_full: str = "", title_ru: str = "", title_full: str = "",
pub_status: str = "unknown"): pub_status: str = "unknown",
description: str = "", tags: str = "",
cover_url: str = ""):
self.conn.execute(""" self.conn.execute("""
UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?, UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?,
chapters_total=?, updated_at=? WHERE url=? chapters_total=?, updated_at=?,
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url)) description=?, tags=?, cover_url=?
WHERE url=?
""", (title, title_ru, title_full, pub_status, chapters_total, _now(),
description or None, tags or None, cover_url or None, url))
self.conn.commit() self.conn.commit()
def set_folder_name(self, url: str, folder_name: str): def set_folder_name(self, url: str, folder_name: str):
@@ -155,6 +429,26 @@ class StateDB:
""", (status, _now(), url)) """, (status, _now(), url))
self.conn.commit() self.conn.commit()
def set_manga_last_error(self, manga_url: str, error: Optional[str]) -> None:
self.conn.execute(
"UPDATE mangas SET last_error=?, updated_at=? WHERE url=?",
(error, _now(), manga_url)
)
self.conn.commit()
def get_mangas_by_source(self, source_id: int) -> list[dict]:
cur = self.conn.execute(
"SELECT url, last_error FROM mangas WHERE source_id=?", (source_id,)
)
return [dict(r) for r in cur.fetchall()]
def update_source_settings(self, source_id: int, settings: dict) -> None:
self.conn.execute(
"UPDATE sources SET settings=? WHERE id=?",
(json.dumps(settings), source_id)
)
self.conn.commit()
def mark_started(self, url: str) -> str: def mark_started(self, url: str) -> str:
"""Записывает время начала загрузки. Возвращает timestamp.""" """Записывает время начала загрузки. Возвращает timestamp."""
ts = _now() ts = _now()
@@ -185,17 +479,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 +501,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 +575,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 +662,91 @@ 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:
return datetime.utcnow().isoformat()

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,12 @@ 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
import json as _json
from .sources.base import Chapter, MangaInfo, AuthRequiredError
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 +23,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,18 +54,52 @@ 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
# Inject auth token from source DB settings
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
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) try:
await info_page.close() manga = await source.get_manga_info(info_page, url)
except AuthRequiredError as e:
await info_page.close()
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": e.source_slug, "finished_at": finished_ts})
return
if not manga: if not manga:
await info_page.close()
await db_call(db.update_manga_status, url, "failed") await db_call(db.update_manga_status, url, "failed")
await emit({"type": "manga_failed", "url": url, await emit({"type": "manga_failed", "url": url,
"error": "Не удалось получить информацию о манге"}) "error": "Не удалось получить информацию о манге"})
return return
import json as _json_mod
await db_call( await db_call(
db.update_manga_info, db.update_manga_info,
url, url,
@@ -81,6 +108,9 @@ async def download_manga(
title_ru=manga.title_ru, title_ru=manga.title_ru,
title_full=manga.title_full, title_full=manga.title_full,
pub_status=manga.pub_status, pub_status=manga.pub_status,
description=manga.description,
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
) )
await emit({ await emit({
"type": "manga_info", "type": "manga_info",
@@ -96,11 +126,17 @@ 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)
# Скачиваем обложку для CBZ-формата (info_page ещё открыта — контекст браузера жив)
if manga.cover_url and fmt in ("cbz", "all"):
await _download_cover(manga.cover_url, manga_dir, url, info_page)
await info_page.close()
for ch in manga.chapters: for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume) await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
@@ -178,22 +214,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 +248,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 "",
@@ -223,6 +260,7 @@ async def download_manga(
source_url=url, source_url=url,
summary=manga.description, summary=manga.description,
genre=", ".join(manga.genres) if manga.genres else "", genre=", ".join(manga.genres) if manga.genres else "",
tags=", ".join(manga.tags) if manga.tags else "",
) )
for f in formats: for f in formats:
out_file = manga_dir / f"{ch_name}.{f}" out_file = manga_dir / f"{ch_name}.{f}"
@@ -261,6 +299,8 @@ async def download_manga(
"chapters_total": len(manga.chapters), "chapters_total": len(manga.chapters),
}) })
except AuthRequiredError:
raise
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}", "Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
@@ -276,14 +316,70 @@ async def download_manga(
tasks = [process_chapter(ch) for ch in to_download] tasks = [process_chapter(ch) for ch in to_download]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Логируем неожиданные исключения из gather # Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
auth_slug = None
for ch, res in zip(to_download, results): for ch, res in zip(to_download, results):
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError): if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception( logger.exception(
"gather: необработанное исключение Т{} Гл.{} '{}': {}", "gather: необработанное исключение Т{} Гл.{} '{}': {}",
ch.volume, ch.number, ch.title, res, ch.volume, ch.number, ch.title, res,
) )
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
# ── Автоповтор неудачных глав (до 3 раз) ─────────────────────
MAX_AUTO_RETRIES = 3
for retry_attempt in range(1, MAX_AUTO_RETRIES + 1):
stats = await db_call(db.get_chapter_stats, url)
if stats["failed"] + stats["partial"] == 0:
break
failed_count = stats["failed"] + stats["partial"]
logger.info(
"Автоповтор {}/{}: {} неудачных/частичных глав для {}",
retry_attempt, MAX_AUTO_RETRIES, failed_count, url,
)
await emit({
"type": "retry_errors_auto",
"url": url,
"attempt": retry_attempt,
"max_attempts": MAX_AUTO_RETRIES,
"failed_count": failed_count,
})
await db_call(db.reset_failed_chapters, url)
all_ch_rows = await db_call(db.get_all_chapters, url)
pending_urls = {c["chapter_url"] for c in all_ch_rows if c["status"] == "pending"}
retry_chapters = [ch for ch in manga.chapters if ch.url in pending_urls]
if not retry_chapters:
break
retry_results = await asyncio.gather(
*[process_chapter(ch) for ch in retry_chapters],
return_exceptions=True,
)
auth_slug = None
for ch, res in zip(retry_chapters, retry_results):
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception(
"retry {}: необработанное исключение Т{} Гл.{} '{}': {}",
retry_attempt, ch.volume, ch.number, ch.title, res,
)
if auth_slug:
await db_call(db.update_manga_status, url, "stopped")
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
finished_ts = await db_call(db.mark_finished, url)
await emit({"type": "auth_required", "url": url,
"source_slug": auth_slug, "finished_at": finished_ts})
return
real_done = await db_call(db.sync_chapters_done, url) real_done = await db_call(db.sync_chapters_done, url)
await db_call(db.update_manga_status, url, "done") await db_call(db.update_manga_status, url, "done")
finished_ts = await db_call(db.mark_finished, url) finished_ts = await db_call(db.mark_finished, url)
@@ -298,6 +394,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)
@@ -308,6 +406,244 @@ async def download_manga(
db.close() db.close()
async def validate_manga(
url: str,
output_dir: Path = OUTPUT_DIR,
on_event=None,
) -> dict:
"""
Проверяет целостность скачанной манги, сравнивая с сайтом.
- Получает актуальный список глав с сайта
- Добавляет новые главы в БД
- Для скачанных глав: проверяет наличие файлов и количество страниц
- Возвращает dict с chapters_to_redownload и статистикой
"""
async def emit(event: dict):
if on_event:
try:
await on_event(event)
except Exception as e:
logger.debug("on_event error: {}", e)
db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try:
await emit({"type": "validate_started", "url": url})
source = get_source_for_url(url, db)
if source is None:
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 emit({"type": "validate_error", "url": url,
"error": "Источник не определён. Выберите источник в настройках манги."})
return {"ok": False, "chapters_to_redownload": []}
if hasattr(source, "auth_token"):
_src_row = await db_call(db.get_source_by_slug, source.slug)
if _src_row:
_settings_raw = _src_row.get("settings") or "{}"
try:
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
except Exception:
_settings = {}
source.auth_token = _settings.get("auth_token") or None
manga_row = await db_call(db.get_manga, url)
fmt = (manga_row or {}).get("format", "cbz")
fmt_list = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
async with BrowserManager(headless=True) as bm:
ctx, info_page = await bm.new_page()
try:
manga = await source.get_manga_info(info_page, url)
except Exception as e:
logger.error("validate: get_manga_info ошибка для {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
await info_page.close()
if not manga:
await emit({"type": "validate_error", "url": url,
"error": "Не удалось получить информацию о манге с сайта"})
return {"ok": False, "chapters_to_redownload": []}
for ch in manga.chapters:
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
all_ch_rows = await db_call(db.get_all_chapters, url)
db_chapters = {c["chapter_url"]: c for c in all_ch_rows}
new_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "pending"
]
done_chapters = [
ch for ch in manga.chapters
if db_chapters.get(ch.url, {}).get("status") == "done"
]
to_redownload: set = set()
fast_issues = 0
for ch in done_chapters:
db_ch = db_chapters[ch.url]
if db_ch.get("pages_total", 0) > 0 and db_ch.get("pages_done", 0) < db_ch["pages_total"]:
to_redownload.add(ch.url)
fast_issues += 1
continue
for f in fmt_list:
fpath = db_ch.get(f"output_{f}")
if fpath and not Path(fpath).exists():
to_redownload.add(ch.url)
fast_issues += 1
break
chapters_for_deep = [
ch for ch in done_chapters if ch.url not in to_redownload
]
site_mismatched = 0
checked = 0
has_page_count = hasattr(source, "get_chapter_page_count")
if has_page_count and chapters_for_deep:
sem = asyncio.Semaphore(2)
count_lock = asyncio.Lock()
async def check_one(ch: Chapter) -> None:
nonlocal checked, site_mismatched
async with sem:
db_ch = db_chapters[ch.url]
ch_page = await ctx.new_page()
mismatch = False
site_count = 0
try:
site_count = await source.get_chapter_page_count(
ch_page, ch.url, url
)
except AuthRequiredError:
raise
except Exception as e:
logger.warning(
"validate page count Т{} Гл.{}: {}", ch.volume, ch.number, e
)
finally:
await ch_page.close()
pages_have = db_ch.get("pages_done", 0)
if site_count > 0 and site_count != pages_have:
mismatch = True
logger.info(
"validate: Т{} Гл.{} — сайт {} стр., у нас {} → повтор",
ch.volume, ch.number, site_count, pages_have,
)
async with count_lock:
checked += 1
if mismatch:
to_redownload.add(ch.url)
site_mismatched += 1
await emit({
"type": "validate_progress",
"url": url,
"checked": checked,
"total": len(chapters_for_deep),
"chapter_number": ch.number,
"volume": ch.volume,
"mismatch": mismatch,
"site_count": site_count,
})
results = await asyncio.gather(
*[check_one(ch) for ch in chapters_for_deep],
return_exceptions=True,
)
auth_slug = None
for res in results:
if isinstance(res, AuthRequiredError):
auth_slug = res.source_slug
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
logger.exception("validate gather exception: {}", res)
if auth_slug:
await emit({"type": "validate_error", "url": url,
"error": f"auth_required:{auth_slug}"})
return {"ok": False, "chapters_to_redownload": []}
to_redownload_list = list(to_redownload)
result = {
"ok": True,
"url": url,
"site_chapters": len(manga.chapters),
"new_chapters": len(new_chapters),
"fast_issues": fast_issues,
"site_mismatched": site_mismatched,
"total_to_redownload": len(to_redownload_list),
"chapters_to_redownload": to_redownload_list,
}
await emit({
"type": "validate_done",
**{k: v for k, v in result.items() if k != "chapters_to_redownload"},
})
return result
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("validate_manga {}: {}", url, e)
await emit({"type": "validate_error", "url": url, "error": str(e)})
return {"ok": False, "chapters_to_redownload": []}
finally:
db.close()
def _cover_ext_from_url(url: str) -> str:
import re as _re
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 _download_cover(cover_url: str, manga_dir: Path, manga_url: str, page) -> Optional[Path]:
"""Скачивает обложку в manga_dir/cover.{ext}. Использует существующий Playwright page."""
from urllib.parse import urlparse as _urlparse
try:
parsed = _urlparse(manga_url)
referer = f"{parsed.scheme}://{parsed.netloc}/"
headers = {
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
"Referer": referer,
}
response = await page.context.request.get(cover_url, headers=headers)
if not response.ok:
logger.warning("Обложка: HTTP {} для {}", response.status, cover_url)
return None
body = await response.body()
if len(body) < 500:
logger.warning("Обложка: слишком малый ответ ({} байт)", len(body))
return None
ext = _cover_ext_from_url(cover_url)
cover_path = manga_dir / f"cover{ext}"
cover_path.write_bytes(body)
logger.info("Обложка сохранена: {} ({} байт)", cover_path.name, len(body))
return cover_path
except Exception as e:
logger.warning("Ошибка скачивания обложки {}: {}", cover_url, e)
return None
async def check_for_updates( async def check_for_updates(
url: str, url: str,
on_event: Optional[Callable] = None, on_event: Optional[Callable] = None,
@@ -324,35 +660,71 @@ async def check_for_updates(
pass pass
db = StateDB() db = StateDB()
db_lock = asyncio.Lock()
async def db_call(fn, *args, **kwargs):
async with db_lock:
return fn(*args, **kwargs)
try: try:
db.set_last_checked(url) await db_call(db.set_last_checked, url)
db.add_history(manga_url=url, event_type="check_started") await db_call(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 = 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 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()
if not manga: if not manga:
await page.close()
return [] return []
# Обновляем pub_status и количество глав import json as _json_mod
db.update_manga_info( # Обновляем pub_status, количество глав и мета-поля
await db_call(
db.update_manga_info,
url, url,
title=manga.title_ru or manga.title, title=manga.title_ru or manga.title,
chapters_total=len(manga.chapters), chapters_total=len(manga.chapters),
title_ru=manga.title_ru, title_ru=manga.title_ru,
title_full=manga.title_full, title_full=manga.title_full,
pub_status=manga.pub_status, pub_status=manga.pub_status,
description=manga.description,
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
cover_url=manga.cover_url,
) )
# Обновляем обложку если манга сохраняется как cbz
manga_row = await db_call(db.get_manga, url)
manga_fmt = (manga_row or {}).get("format", "cbz")
if manga.cover_url and manga_fmt in ("cbz", "all"):
folder_name = (
(manga_row.get("folder_name") if manga_row else None)
or safe_name(manga.title_ru or manga.title)
)
manga_dir = OUTPUT_DIR / folder_name
if manga_dir.exists():
await _download_cover(manga.cover_url, manga_dir, url, page)
await page.close()
# Находим главы которых ещё нет в БД # Находим главы которых ещё нет в БД
known = {ch["chapter_url"] for ch in db.get_all_chapters(url)} known = {ch["chapter_url"] for ch in await db_call(db.get_all_chapters, url)}
new_chapters = [ch for ch in manga.chapters if ch.url not in known] new_chapters = [ch for ch in manga.chapters if ch.url not in known]
for ch in new_chapters: for ch in new_chapters:
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume) await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
db.add_history( await db_call(
db.add_history,
manga_url=url, manga_url=url,
event_type="new_chapter_found", event_type="new_chapter_found",
chapter_url=ch.url, chapter_url=ch.url,
@@ -368,7 +740,8 @@ async def check_for_updates(
"chapter_number": ch.number, "chapter_number": ch.number,
}) })
db.add_history( await db_call(
db.add_history,
manga_url=url, manga_url=url,
event_type="check_done", event_type="check_done",
details=f"Найдено новых: {len(new_chapters)}", details=f"Найдено новых: {len(new_chapters)}",