Compare commits
6 Commits
88bf301b60
...
bc7b5bfe37
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7b5bfe37 | |||
| 43597be020 | |||
| 469fd1ba94 | |||
| 9d5d840898 | |||
| b4e4a51ae5 | |||
| 87b692ba49 |
206
ARCHITECTURE.md
206
ARCHITECTURE.md
@@ -7,6 +7,7 @@
|
|||||||
3. [Стек технологий](#3-стек-технологий)
|
3. [Стек технологий](#3-стек-технологий)
|
||||||
4. [Схема архитектуры](#4-схема-архитектуры)
|
4. [Схема архитектуры](#4-схема-архитектуры)
|
||||||
5. [Модули бэкенда](#5-модули-бэкенда)
|
5. [Модули бэкенда](#5-модули-бэкенда)
|
||||||
|
- [auth.py](#authpy)
|
||||||
- [browser.py](#browserpy)
|
- [browser.py](#browserpy)
|
||||||
- [scraper.py](#scraperpy)
|
- [scraper.py](#scraperpy)
|
||||||
- [exporter.py](#exporterpy)
|
- [exporter.py](#exporterpy)
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
|
|
||||||
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
|
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
|
||||||
|
|
||||||
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket.
|
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. Требует входа — поддерживает многопользовательский режим с ролями `admin` / `user`.
|
||||||
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
|
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -41,6 +42,7 @@ manga/
|
|||||||
├── src/ # Весь бэкенд-код (Python-пакет)
|
├── src/ # Весь бэкенд-код (Python-пакет)
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── api.py # FastAPI-приложение, REST + WebSocket
|
│ ├── api.py # FastAPI-приложение, REST + WebSocket
|
||||||
|
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
|
||||||
│ ├── browser.py # Обёртка над Playwright/Chromium
|
│ ├── browser.py # Обёртка над Playwright/Chromium
|
||||||
│ ├── cli.py # CLI-команды (click)
|
│ ├── cli.py # CLI-команды (click)
|
||||||
│ ├── downloader.py # (legacy, не используется в web-режиме)
|
│ ├── downloader.py # (legacy, не используется в web-режиме)
|
||||||
@@ -100,6 +102,7 @@ manga/
|
|||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ FastAPI (api.py) │
|
│ FastAPI (api.py) │
|
||||||
│ │
|
│ │
|
||||||
|
│ Auth middleware (cookie/session)│
|
||||||
│ REST endpoints WebSocket /ws │
|
│ REST endpoints WebSocket /ws │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ asyncio.Queue ws_manager │
|
│ asyncio.Queue ws_manager │
|
||||||
@@ -129,6 +132,25 @@ manga/
|
|||||||
|
|
||||||
## 5. Модули бэкенда
|
## 5. Модули бэкенда
|
||||||
|
|
||||||
|
### auth.py
|
||||||
|
|
||||||
|
**Отвечает за:** хеширование паролей и генерацию токенов сессий.
|
||||||
|
|
||||||
|
**Константы:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
COOKIE_NAME = "manga_session"
|
||||||
|
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
|
||||||
|
```
|
||||||
|
|
||||||
|
**Функции:**
|
||||||
|
|
||||||
|
- `hash_password(password)` → `str` — хеширует пароль методом PBKDF2-SHA256, 260 000 итераций, случайная соль. Формат результата: `pbkdf2:260000:<salt_hex>:<key_hex>`.
|
||||||
|
- `verify_password(password, hashed)` → `bool` — проверяет пароль по хешу через `hmac.compare_digest` (защита от timing-атак).
|
||||||
|
- `generate_session_token()` → `str` — генерирует 48-байтный URL-safe токен (`secrets.token_urlsafe`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### browser.py
|
### browser.py
|
||||||
|
|
||||||
**Отвечает за:** запуск и управление Playwright Chromium.
|
**Отвечает за:** запуск и управление Playwright Chromium.
|
||||||
@@ -248,9 +270,11 @@ class MangaInfo:
|
|||||||
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
|
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
|
||||||
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
|
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
|
||||||
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
||||||
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
|
| `chapters_done` | INTEGER | Денормализованный счётчик¹ |
|
||||||
| `last_checked_at` | TEXT | Время последней проверки новых глав |
|
| `last_checked_at` | TEXT | Время последней проверки новых глав |
|
||||||
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
|
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
|
||||||
|
| `source_id` | INTEGER | FK → sources.id — источник манги |
|
||||||
|
| `added_by` | INTEGER | FK → users.id — кто добавил мангу |
|
||||||
|
|
||||||
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
|
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
|
||||||
|
|
||||||
@@ -275,15 +299,59 @@ class MangaInfo:
|
|||||||
| `new_chapter_found` | Найдена новая глава при проверке |
|
| `new_chapter_found` | Найдена новая глава при проверке |
|
||||||
| `check_started` / `check_done` | Начало/конец проверки обновлений |
|
| `check_started` / `check_done` | Начало/конец проверки обновлений |
|
||||||
|
|
||||||
|
**`sources`** — источники (определяются в коде приложения):
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `slug` | TEXT UNIQUE | Идентификатор источника (`readmanga`) |
|
||||||
|
| `display_name` | TEXT | Отображаемое название |
|
||||||
|
| `settings` | TEXT | JSON-настройки источника |
|
||||||
|
|
||||||
|
**`source_domains`** — домены, привязанные к источникам:
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `source_id` | INTEGER | FK → sources.id |
|
||||||
|
| `domain` | TEXT UNIQUE | Домен без схемы и www (`readmanga.ru`) |
|
||||||
|
|
||||||
|
При первом запуске домены ReadManga автоматически засеиваются из списка `_DEFAULT_READMANGA_DOMAINS` в коде.
|
||||||
|
|
||||||
|
**`users`** — пользователи:
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `username` | TEXT UNIQUE | Логин |
|
||||||
|
| `password` | TEXT | Хеш пароля (pbkdf2:iterations:salt:key) |
|
||||||
|
| `role` | TEXT | `admin` / `user` |
|
||||||
|
| `is_env_admin` | INTEGER | 1 — системный администратор из `AUTH_LOGIN`/`AUTH_PASSWORD`; его пароль нельзя изменить через интерфейс |
|
||||||
|
| `created_at` / `updated_at` | TEXT | ISO-8601 timestamp |
|
||||||
|
|
||||||
|
**`sessions`** — активные сессии:
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `token` | TEXT PK | URL-safe токен (48 байт) |
|
||||||
|
| `user_id` | INTEGER | FK → users.id ON DELETE CASCADE |
|
||||||
|
| `created_at` / `expires_at` | TEXT | Сессия действительна 30 дней |
|
||||||
|
|
||||||
#### Ключевые методы
|
#### Ключевые методы
|
||||||
|
|
||||||
- `add_manga(url, fmt)` → `bool` — добавляет, возвращает `False` если уже есть.
|
- `add_manga(url, fmt, source_id, added_by)` → `bool` — добавляет, возвращает `False` если уже есть.
|
||||||
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
|
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
|
||||||
- `chapter_status(chapter_url)` → `str | None`.
|
- `chapter_status(chapter_url)` → `str | None`.
|
||||||
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число.
|
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters.
|
||||||
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
|
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
|
||||||
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
|
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
|
||||||
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
|
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
|
||||||
|
- `create_user(username, hashed_password, role, is_env_admin)` → `dict` — создаёт пользователя.
|
||||||
|
- `get_user_by_id(id)` / `get_user_by_username(username)` → `dict | None`.
|
||||||
|
- `get_all_users()` → `list[dict]` — все пользователи без поля `password`.
|
||||||
|
- `update_user(user_id, **kwargs)` — обновляет разрешённые поля (`username`, `password`, `role`).
|
||||||
|
- `delete_user(user_id)` — удаляет пользователя и все его сессии.
|
||||||
|
- `create_session(token, user_id, expires_at)` — создаёт сессию.
|
||||||
|
- `get_session(token)` → `dict | None` — возвращает только не истёкшие сессии.
|
||||||
|
- `cleanup_expired_sessions()` → `int` — удаляет истёкшие сессии.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,7 +409,17 @@ async with sem: # ограничение параллельности
|
|||||||
|
|
||||||
### api.py
|
### api.py
|
||||||
|
|
||||||
**Отвечает за:** FastAPI-приложение — HTTP-сервер, очередь загрузок, планировщик обновлений.
|
**Отвечает за:** FastAPI-приложение — HTTP-сервер, аутентификация, очередь загрузок, планировщик обновлений.
|
||||||
|
|
||||||
|
#### Auth-зависимости (Depends)
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_current_user(request) # читает токен из cookie, валидирует сессию → dict пользователя
|
||||||
|
require_admin(user) # get_current_user + проверка role == "admin"
|
||||||
|
_check_manga_access(manga, user) # admin: полный доступ; user: только свои манги
|
||||||
|
```
|
||||||
|
|
||||||
|
Все endpoint'ы (кроме `/api/login` и `/api/auth/check`) требуют валидной сессии.
|
||||||
|
|
||||||
#### Глобальное состояние
|
#### Глобальное состояние
|
||||||
|
|
||||||
@@ -354,14 +432,17 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
|
|||||||
#### Вспомогательные функции
|
#### Вспомогательные функции
|
||||||
|
|
||||||
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
|
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
|
||||||
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`. Используется везде: `_enrich_manga`, `_manga_detail`, `delete_manga`, `rename_folder`.
|
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
|
||||||
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions` с актуальным словарём `{url: позиция}`. Вызывается после любого изменения очереди: старт/конец задачи в воркере, `prioritize`, `stop`, `resume`, `add_to_queue`, `force_redownload`.
|
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
|
||||||
|
|
||||||
#### Жизненный цикл при старте (`startup_event`)
|
#### Жизненный цикл при старте (`startup_event`)
|
||||||
|
|
||||||
1. Запускает `queue_worker()` как фоновую Task.
|
1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
|
||||||
2. Запускает `update_scheduler()` как фоновую Task.
|
2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
|
||||||
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь).
|
3. Удаляет истёкшие сессии (`cleanup_expired_sessions`).
|
||||||
|
4. **Bootstrap-admin:** если таблица `users` пуста — создаёт пользователя из `AUTH_LOGIN`/`AUTH_PASSWORD` с ролью `admin` и флагом `is_env_admin=True`.
|
||||||
|
5. Запускает `queue_worker()` и `update_scheduler()` как фоновые Task.
|
||||||
|
6. Восстанавливает незавершённые задачи из БД (status `queued`/`downloading` → снова в очередь).
|
||||||
|
|
||||||
#### `queue_worker()`
|
#### `queue_worker()`
|
||||||
|
|
||||||
@@ -373,18 +454,15 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
|
|||||||
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
|
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
|
||||||
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
|
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
|
||||||
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
|
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
|
||||||
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
|
|
||||||
|
|
||||||
#### `_enrich_manga(m, db)`
|
#### `_enrich_manga(m, db)`
|
||||||
|
|
||||||
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
|
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
|
||||||
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не стальная колонка).
|
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
|
||||||
- `size_bytes` / `size_human` — размер папки на диске.
|
- `size_bytes` / `size_human` — размер папки на диске.
|
||||||
- `is_active` — есть ли Task в `active_tasks`.
|
- `is_active` — есть ли Task в `active_tasks`.
|
||||||
- `errors_count` — сумма failed и partial глав.
|
- `errors_count` — сумма failed и partial глав.
|
||||||
|
|
||||||
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### cli.py
|
### cli.py
|
||||||
@@ -429,6 +507,36 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
|
|
||||||
Базовый URL: `http://localhost:8000`
|
Базовый URL: `http://localhost:8000`
|
||||||
|
|
||||||
|
Все endpoint'ы, кроме `/api/login` и `/api/auth/check`, требуют валидной сессии (cookie `manga_session`). Endpoint'ы с пометкой `[admin]` доступны только пользователям с ролью `admin`.
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET` | `/api/auth/check` | Проверить текущую сессию. Возвращает `{authenticated, user: {id, username, role, is_env_admin}}` |
|
||||||
|
| `POST` | `/api/login` | Войти `{login, password}`. Устанавливает cookie сессии на 30 дней |
|
||||||
|
| `POST` | `/api/logout` | Выйти. Удаляет сессию из БД и cookie |
|
||||||
|
|
||||||
|
### Управление пользователями `[admin]`
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET` | `/api/users` | Список всех пользователей (без паролей) |
|
||||||
|
| `POST` | `/api/users` | Создать пользователя `{username, password, role}` |
|
||||||
|
| `PATCH` | `/api/users/{user_id}` | Изменить `{username?, password?, role?}`. Для `is_env_admin=1` смена пароля заблокирована. Обычный пользователь может менять только свой пароль |
|
||||||
|
| `DELETE` | `/api/users/{user_id}` | Удалить пользователя. Системного администратора и последнего admin удалить нельзя |
|
||||||
|
|
||||||
|
### Источники
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET` | `/api/sources` | Список источников с доменами и настройками |
|
||||||
|
| `GET` | `/api/resolve-source?url=` | Определить источник по URL манги |
|
||||||
|
| `POST` | `/api/sources/{id}/domains` `[admin]` | Добавить домен к источнику `{domain}` |
|
||||||
|
| `DELETE` | `/api/sources/{id}/domains/{domain}` `[admin]` | Удалить домен у источника |
|
||||||
|
|
||||||
|
### Манги
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
|
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
|
||||||
@@ -436,15 +544,21 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
|
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
|
||||||
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
|
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
|
||||||
| `POST` | `/api/mangas/resume?url=` | Возобновить |
|
| `POST` | `/api/mangas/resume?url=` | Возобновить |
|
||||||
| `POST` | `/api/mangas/prioritize?url=` | Переместить в начало очереди (вытесняет текущую) |
|
| `POST` | `/api/mangas/prioritize?url=` `[admin]` | Переместить в начало очереди (вытесняет текущую) |
|
||||||
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
|
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
|
||||||
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
|
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
|
||||||
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
|
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
|
||||||
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
|
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
|
||||||
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске и обновить пути в БД `{url, folder_name}` |
|
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске `{url, folder_name}` |
|
||||||
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
|
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
|
||||||
| `POST` | `/api/mangas/force_redownload?url=` | Сбросить все главы и поставить в очередь заново |
|
| `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
|
||||||
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
|
| `POST` | `/api/mangas/switch-source` `[admin]` | Сменить источник манги `{manga_url, source_id}` |
|
||||||
|
| `DELETE` | `/api/mangas?url=` `[admin]` | Удалить мангу из БД |
|
||||||
|
|
||||||
|
### Прочее
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|---------|
|
||||||
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
|
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
|
||||||
| `GET` | `/api/history?limit=&manga_url=` | История событий |
|
| `GET` | `/api/history?limit=&manga_url=` | История событий |
|
||||||
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
|
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
|
||||||
@@ -469,7 +583,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
||||||
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
|
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
|
||||||
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
|
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
|
||||||
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — отправляется при любом изменении очереди |
|
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — при любом изменении очереди |
|
||||||
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
|
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
|
||||||
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
|
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
|
||||||
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
|
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
|
||||||
@@ -479,6 +593,8 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
||||||
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
|
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
|
||||||
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
|
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
|
||||||
|
| `source_domain_added` | `{source_id, domain}` | Добавлен домен к источнику |
|
||||||
|
| `source_domain_removed` | `{source_id, domain}` | Домен удалён у источника |
|
||||||
|
|
||||||
### Клиент → Сервер
|
### Клиент → Сервер
|
||||||
|
|
||||||
@@ -490,16 +606,24 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
|
|
||||||
## 9. Фронтенд
|
## 9. Фронтенд
|
||||||
|
|
||||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк).
|
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
|
||||||
|
|
||||||
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
||||||
|
|
||||||
|
### Экран входа
|
||||||
|
|
||||||
|
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
|
||||||
|
|
||||||
|
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
|
||||||
|
|
||||||
### Архитектура состояния
|
### Архитектура состояния
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const state = {
|
const state = {
|
||||||
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
|
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
|
||||||
chapters: {}, // url → массив глав (загружается по запросу в модалке)
|
chapters: {}, // url → массив глав (загружается по запросу в модалке)
|
||||||
|
currentUser: null, // {id, username, role, is_env_admin}
|
||||||
|
sources: [], // список источников
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -508,24 +632,41 @@ const state = {
|
|||||||
```
|
```
|
||||||
DOMContentLoaded
|
DOMContentLoaded
|
||||||
│
|
│
|
||||||
├─ loadStats() ──────────────────────► GET /api/stats
|
├─ checkAuth() ──────────────────────► GET /api/auth/check
|
||||||
|
│ │ (если ok → initApp())
|
||||||
|
│ └─ showLoginScreen()
|
||||||
│
|
│
|
||||||
├─ connectWS() ──────────────────────► WS /ws
|
└─ initApp()
|
||||||
|
├─ loadStats() ──────────────────► GET /api/stats
|
||||||
|
├─ connectWS() ──────────────────► WS /ws
|
||||||
│ │
|
│ │
|
||||||
│ └─ snapshot event ──────────► state.mangas = enriched list
|
│ └─ snapshot event ───────► state.mangas = enriched list
|
||||||
│ + live events ──────────► state.mangas[url].* обновляется
|
│ + live events ───────► state.mangas[url].* обновляется
|
||||||
│
|
│
|
||||||
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
|
└─ fetch('/api/mangas') ──────────► state.mangas = полный список
|
||||||
(перезаписывает snapshot если пришёл раньше)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
||||||
|
|
||||||
### Вкладки
|
### Вкладки
|
||||||
|
|
||||||
- **Манга** — список всех манг, добавление, управление.
|
- **Манга** — список всех манг, добавление, управление.
|
||||||
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
|
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
|
||||||
- **История** — все события из таблицы `history`.
|
- **История** — все события из таблицы `history`.
|
||||||
|
- **Настройки** — управление источниками, пользователями (только admin), смена своего пароля.
|
||||||
|
|
||||||
|
### Вкладка «Настройки»
|
||||||
|
|
||||||
|
При открытии загружает:
|
||||||
|
- Список источников (`GET /api/sources`) с управлением доменами (только admin).
|
||||||
|
- Список пользователей (`GET /api/users`, только admin) — в разделе **Пользователи**.
|
||||||
|
- Раздел **Сменить пароль** — скрыт для системного администратора (`is_env_admin=true`).
|
||||||
|
|
||||||
|
**Управление пользователями (только admin):**
|
||||||
|
- Создание: кнопка «+ Добавить» → модалка с логином, паролем, ролью.
|
||||||
|
- Редактирование: кнопка ✏️ → модалка. Для системного администратора (`is_env_admin`) поле пароля скрыто.
|
||||||
|
- Удаление: кнопка ✕ (недоступна для системного администратора и для самого себя).
|
||||||
|
- Системный администратор помечен иконкой 🔒 с тултипом.
|
||||||
|
|
||||||
### Модальное окно детали
|
### Модальное окно детали
|
||||||
|
|
||||||
@@ -533,7 +674,7 @@ DOMContentLoaded
|
|||||||
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
|
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
|
||||||
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
|
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
|
||||||
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
|
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
|
||||||
- **↺ Скачать заново** — сбрасывает все главы и ставит в очередь повторно.
|
- **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
|
||||||
|
|
||||||
### Карточки манги (кнопки)
|
### Карточки манги (кнопки)
|
||||||
|
|
||||||
@@ -544,7 +685,7 @@ DOMContentLoaded
|
|||||||
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
|
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
|
||||||
| ▶ | `status` = `stopped` или `failed` | Возобновить |
|
| ▶ | `status` = `stopped` или `failed` | Возобновить |
|
||||||
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
|
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
|
||||||
| ✕ | всегда | Удалить |
|
| ✕ `[admin]` | всегда | Удалить |
|
||||||
|
|
||||||
### Позиции в очереди
|
### Позиции в очереди
|
||||||
|
|
||||||
@@ -558,10 +699,11 @@ DOMContentLoaded
|
|||||||
|
|
||||||
| Переменная | Default | Описание |
|
| Переменная | Default | Описание |
|
||||||
|------------|---------|---------|
|
|------------|---------|---------|
|
||||||
|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
|
||||||
|
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
|
||||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||||
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
|
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
|
||||||
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
|
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
|
||||||
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация |
|
|
||||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||||
|
|
||||||
### Пути (hardcoded в коде)
|
### Пути (hardcoded в коде)
|
||||||
@@ -602,7 +744,7 @@ ports:
|
|||||||
shm_size: "2gb" # Chromium требует shared memory
|
shm_size: "2gb" # Chromium требует shared memory
|
||||||
environment:
|
environment:
|
||||||
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
|
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
|
||||||
- AUTH_LOGIN=...
|
- AUTH_LOGIN=... # системный администратор
|
||||||
- AUTH_PASSWORD=...
|
- AUTH_PASSWORD=...
|
||||||
|
|
||||||
restart: unless-stopped # Автоперезапуск при падении
|
restart: unless-stopped # Автоперезапуск при падении
|
||||||
@@ -624,7 +766,7 @@ docker compose run --rm --entrypoint "" manga \
|
|||||||
|
|
||||||
После остановки контейнера все данные сохраняются на хосте:
|
После остановки контейнера все данные сохраняются на хосте:
|
||||||
- `./output/` — скачанные файлы.
|
- `./output/` — скачанные файлы.
|
||||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
|
- `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
|
||||||
- `./state/manga.log` — логи.
|
- `./state/manga.log` — логи.
|
||||||
|
|
||||||
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
||||||
|
|||||||
369
CODE_REVIEW.md
Normal file
369
CODE_REVIEW.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# Code Review: находки и предложения
|
||||||
|
|
||||||
|
> Статус: **только чтение** — ничего не изменено. Каждый пункт сопровождается предлагаемым исправлением.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Дублирование кода — `_safe_name` / `_safe_chapter_name`
|
||||||
|
|
||||||
|
**Файлы:** `src/api.py:251`, `src/worker.py:26–32`, `src/cli.py` (аналогичные функции)
|
||||||
|
|
||||||
|
Одна и та же функция определена в трёх местах. При изменении логики нужно менять сразу в трёх файлах — риск расхождения.
|
||||||
|
|
||||||
|
**Исправление:** вынести в `src/utils.py`, импортировать везде:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/utils.py
|
||||||
|
import re
|
||||||
|
from .sources.base import Chapter
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
||||||
|
|
||||||
|
def safe_chapter_name(ch: Chapter) -> str:
|
||||||
|
vol = f"v{ch.volume:02d}_" if ch.volume else ""
|
||||||
|
return f"{vol}ch{ch.number:06.1f}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Прямой доступ к `db.conn` в API-эндпоинтах
|
||||||
|
|
||||||
|
**Файлы:** `src/api.py`
|
||||||
|
|
||||||
|
| Место | Строки |
|
||||||
|
|-------|--------|
|
||||||
|
| `_enrich_manga` | 269–278 |
|
||||||
|
| `retry_errors` | 680–688 |
|
||||||
|
| `force_redownload` | 819–823 |
|
||||||
|
| `delete_manga` | 882–885 |
|
||||||
|
| `rename_folder` | 801–803 |
|
||||||
|
|
||||||
|
`api.py` напрямую исполняет SQL через `db.conn.execute(...)` вместо методов `StateDB`. Это означает, что логика разбросана между двумя слоями, и рефакторинг `StateDB` может незаметно сломать эндпоинты.
|
||||||
|
|
||||||
|
Для `retry_errors` и `force_redownload` в `StateDB` уже есть готовый метод `reset_failed_chapters` — он просто не используется в API.
|
||||||
|
|
||||||
|
**Исправление для `retry_errors`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# api.py — было:
|
||||||
|
now = db.conn.execute("SELECT datetime('now')").fetchone()[0]
|
||||||
|
db.conn.execute("UPDATE chapters SET status='pending' ...", (now, url))
|
||||||
|
db.conn.commit()
|
||||||
|
|
||||||
|
# стало:
|
||||||
|
db.reset_failed_chapters(url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Для `delete_manga` и `rename_folder` добавить соответствующие методы в `StateDB`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `datetime.utcnow()` устарел
|
||||||
|
|
||||||
|
**Файлы:** `src/api.py:369`, `src/state.py:628`
|
||||||
|
|
||||||
|
`datetime.utcnow()` объявлен deprecated в Python 3.12. Используется в двух местах.
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/state.py
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
# src/api.py — в login():
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None).isoformat()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `check_for_updates` не использует `db_lock`
|
||||||
|
|
||||||
|
**Файл:** `src/worker.py:343–400`
|
||||||
|
|
||||||
|
`download_manga` тщательно оборачивает все обращения к БД в `db_lock` (`asyncio.Lock`). `check_for_updates` вызывает `db.update_manga_info`, `db.get_all_chapters`, `db.set_last_checked` напрямую без блокировки. При параллельном запуске нескольких авто-обновлений возможна конкуренция записей в одну и ту же строку SQLite.
|
||||||
|
|
||||||
|
**Исправление:** добавить `db_lock` в `check_for_updates` по аналогии с `download_manga`, либо убедиться, что авто-обновления всегда запускаются последовательно (сейчас в `_run_auto_updates` они идут через `for manga in candidates` — это последовательно, но ручной `check_now` и авто-обновление могут пересечься).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Хак `pages_done_count = [0]`
|
||||||
|
|
||||||
|
**Файл:** `src/worker.py:196`
|
||||||
|
|
||||||
|
```python
|
||||||
|
pages_done_count = [0] # мутабельный список вместо nonlocal
|
||||||
|
|
||||||
|
async def on_page(page_idx: int, pages_total: int):
|
||||||
|
pages_done_count[0] += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Это классический обходной путь для `nonlocal` в Python 2. В Python 3 достаточно `nonlocal`.
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
pages_done = 0
|
||||||
|
|
||||||
|
async def on_page(page_idx: int, pages_total: int):
|
||||||
|
nonlocal pages_done
|
||||||
|
pages_done += 1
|
||||||
|
await db_call(db.update_chapter_pages, ch.url, pages_total, pages_done)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Мёртвый код в `StateDB`
|
||||||
|
|
||||||
|
**Файл:** `src/state.py:405–407`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def increment_manga_chapters_done(self, url: str):
|
||||||
|
# Оставлен для совместимости, но не используется в воркере
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Метод ничего не делает и нигде не вызывается.
|
||||||
|
|
||||||
|
**Исправление:** удалить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Отложенный импорт `BrowserManager` в `_fetch_preview`
|
||||||
|
|
||||||
|
**Файл:** `src/api.py:548`
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _fetch_preview(url: str):
|
||||||
|
try:
|
||||||
|
from .browser import BrowserManager # импорт внутри функции
|
||||||
|
```
|
||||||
|
|
||||||
|
`BrowserManager` уже импортируется в `worker.py`. Импорт внутри функции — признак того, что его пытались скрыть из-за каких-то проблем при старте, но сейчас оснований для этого нет. Это замедляет первый вызов и затрудняет поиск зависимостей.
|
||||||
|
|
||||||
|
**Исправление:** добавить `from .browser import BrowserManager` в топ-уровневые импорты `api.py`.
|
||||||
|
|
||||||
|
Аналогично — `import shutil` внутри тел `rename_folder` (строка 789) и `delete_manga` (строка 879). Вынести в топ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. O(n²) назначение позиций в очереди
|
||||||
|
|
||||||
|
**Файл:** `src/api.py:486–491`
|
||||||
|
|
||||||
|
```python
|
||||||
|
queue_list = list(download_queue._queue)
|
||||||
|
for i, job in enumerate(queue_list):
|
||||||
|
for r in result: # ← внутренний цикл по всем мангам
|
||||||
|
if r["url"] == job["url"]:
|
||||||
|
r["queue_position"] = i + 1
|
||||||
|
```
|
||||||
|
|
||||||
|
При 100 мангах в очереди и 100 в БД — 10 000 итераций на каждый запрос `/api/mangas`.
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
queue_positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)}
|
||||||
|
for r in result:
|
||||||
|
r["queue_position"] = queue_positions.get(r["url"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Утечка памяти в `_export_pdf_pillow`
|
||||||
|
|
||||||
|
**Файл:** `src/exporter.py:131–135`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||||
|
from PIL import Image
|
||||||
|
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||||
|
if pil_images:
|
||||||
|
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||||
|
# pil_images не закрываются — файловые дескрипторы висят до GC
|
||||||
|
```
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||||
|
from PIL import Image
|
||||||
|
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||||
|
try:
|
||||||
|
if pil_images:
|
||||||
|
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||||
|
finally:
|
||||||
|
for img in pil_images:
|
||||||
|
img.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Потенциальный SQL-инъекционный путь в `mark_done`
|
||||||
|
|
||||||
|
**Файл:** `src/state.py:453–459`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||||
|
col = f"output_{fmt}"
|
||||||
|
self.conn.execute(f"""
|
||||||
|
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||||
|
WHERE chapter_url=?
|
||||||
|
""", (output_path, _now(), chapter_url))
|
||||||
|
```
|
||||||
|
|
||||||
|
Сейчас `fmt` всегда приходит из `["cbz", "pdf", "epub"]` в `worker.py`, поэтому эксплуатации нет. Но паттерн хрупкий: если завтра `fmt` придёт от пользователя через API, это станет инъекцией.
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
_ALLOWED_FMTS = {"cbz", "pdf", "epub"}
|
||||||
|
|
||||||
|
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||||
|
if fmt not in _ALLOWED_FMTS:
|
||||||
|
raise ValueError(f"Unknown format: {fmt}")
|
||||||
|
col = f"output_{fmt}"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Неиспользуемый метод `BrowserManager.navigate()`
|
||||||
|
|
||||||
|
**Файл:** `src/browser.py`
|
||||||
|
|
||||||
|
`BrowserManager` содержит метод `navigate()`, который нигде не вызывается — `readmanga.py` определяет собственный `_navigate`. Это мёртвый код.
|
||||||
|
|
||||||
|
**Исправление:** удалить `navigate()` из `BrowserManager` или убрать `_navigate` из `readmanga.py` и использовать единый метод.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `cli.py` использует устаревший шим вместо реестра источников
|
||||||
|
|
||||||
|
**Файл:** `src/cli.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .scraper import get_manga_info, get_chapter_images_and_download # shim
|
||||||
|
```
|
||||||
|
|
||||||
|
`scraper.py` — это обёртка-пустышка, делегирующая в `ReadmangaSource`. CLI не использует `SourceRegistry`, не поддерживает `MangaMeta` полноценно. При добавлении нового источника CLI не заработает автоматически.
|
||||||
|
|
||||||
|
**Исправление:** переписать `cli.py` на использование `registry` и `get_source_for_url`, как это сделано в `worker.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Двойное чтение тела ответа в `saveRenameFolder`
|
||||||
|
|
||||||
|
**Файл:** `frontend/index.html`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function saveRenameFolder() {
|
||||||
|
const r = await fetch('/api/mangas/rename_folder', ...);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json(); // ← первое чтение
|
||||||
|
...
|
||||||
|
}
|
||||||
|
const data = await r.json(); // ← второе чтение (тело уже прочитано!)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Response.body` — это `ReadableStream`, читается один раз. Второй `r.json()` вернёт ошибку или пустой объект.
|
||||||
|
|
||||||
|
**Исправление:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
showError(data.detail || 'Ошибка');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. `escHtml()` не защищает от JS-инъекции в `onclick`
|
||||||
|
|
||||||
|
**Файл:** `frontend/index.html` — различные места типа:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
`<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', ...)">
|
||||||
|
```
|
||||||
|
|
||||||
|
`escHtml` экранирует HTML-спецсимволы (`<`, `>`, `&`), но не защищает от инъекции в контекст JavaScript-строки. Если `u.username` содержит `'); evil() //` — экранирование не поможет.
|
||||||
|
|
||||||
|
**Исправление:** вместо `onclick="..."` в строке шаблона использовать `data-*` атрибуты + делегирование событий:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
`<button class="edit-user-btn" data-id="${u.id}" data-username="${escAttr(u.username)}">`
|
||||||
|
|
||||||
|
// один раз:
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.edit-user-btn');
|
||||||
|
if (btn) openEditUserModal(+btn.dataset.id, btn.dataset.username, ...);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Дублирование `forceRedownload` / `forceRedownloadModal`
|
||||||
|
|
||||||
|
**Файл:** `frontend/index.html`
|
||||||
|
|
||||||
|
Две функции с практически идентичным телом — `forceRedownload(url)` и `forceRedownloadModal(url)`. Отличие только в том, что вторая закрывает модал перед вызовом.
|
||||||
|
|
||||||
|
**Исправление:** одна функция с необязательным флагом или вызов `closeModal()` перед `forceRedownload()` там, где нужно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. `check_for_updates` в `worker.py` импортирует `scraper` шим
|
||||||
|
|
||||||
|
**Файл:** `src/worker.py:16`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .scraper import get_manga_info, get_chapter_images_and_download # shim для обратной совместимости
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти символы нигде в файле не используются — `worker.py` работает через `registry`. Мёртвый импорт.
|
||||||
|
|
||||||
|
**Исправление:** удалить строку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Одиночный SQLite-коннект с `check_same_thread=False` в async-среде
|
||||||
|
|
||||||
|
**Файл:** `src/state.py:27`
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый `StateDB()` создаёт новый коннект, но внутри одного воркера `db` живёт на всё время скачивания манги. `check_same_thread=False` отключает встроенную защиту SQLite от использования коннекта в нескольких потоках. В текущей архитектуре с `asyncio` и `db_lock` это безопасно, но хрупко — одно забытое `await` без блокировки может привести к `database is locked` или повреждению данных.
|
||||||
|
|
||||||
|
**Рекомендация:** ничего не менять прямо сейчас, но при следующем крупном рефакторинге рассмотреть `aiosqlite` или SQLite WAL-режим (`PRAGMA journal_mode=WAL`) для снижения вероятности блокировок при параллельных читателях.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная таблица приоритетов
|
||||||
|
|
||||||
|
| # | Файл | Проблема | Приоритет |
|
||||||
|
|---|------|----------|-----------|
|
||||||
|
| 2 | api.py | Прямой `db.conn` в эндпоинтах (retry, force, delete) | Высокий |
|
||||||
|
| 8 | api.py | O(n²) очередь позиций | Высокий |
|
||||||
|
| 13 | frontend | Двойное чтение `r.json()` — баг | Высокий |
|
||||||
|
| 4 | worker.py | `check_for_updates` без `db_lock` | Средний |
|
||||||
|
| 9 | exporter.py | Утечка PIL-объектов в PDF Pillow | Средний |
|
||||||
|
| 10 | state.py | Хрупкий f-string в `mark_done` | Средний |
|
||||||
|
| 3 | api/state | `datetime.utcnow()` deprecated | Низкий |
|
||||||
|
| 1 | api/worker/cli | Дублирование `_safe_name` | Низкий |
|
||||||
|
| 5 | worker.py | Хак `pages_done_count = [0]` | Низкий |
|
||||||
|
| 6 | state.py | Мёртвый метод `increment_manga_chapters_done` | Низкий |
|
||||||
|
| 7 | api.py | Поздний `import` внутри функций | Низкий |
|
||||||
|
| 11 | browser.py | Мёртвый метод `navigate()` | Низкий |
|
||||||
|
| 12 | cli.py | Устаревший шим, нет поддержки реестра | Низкий |
|
||||||
|
| 14 | frontend | JS-инъекция через `onclick` + `escHtml` | Низкий (internal tool) |
|
||||||
|
| 15 | frontend | Дублирование `forceRedownload*` | Низкий |
|
||||||
|
| 16 | worker.py | Мёртвый импорт scraper shim | Низкий |
|
||||||
|
| 17 | state.py | `check_same_thread=False` в async | На будущее |
|
||||||
352
PLAN_MULTI_SOURCE.md
Normal file
352
PLAN_MULTI_SOURCE.md
Normal 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 + предупреждение + ручной выбор для неизвестных доменов
|
||||||
|
- Диалог смены источника с предупреждением о перепривязке домена
|
||||||
|
- Вкладка «Настройки → Источники»
|
||||||
|
|
||||||
|
|
||||||
37
README.md
37
README.md
@@ -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/
|
|||||||
- Физическая папка на диске будет переименована.
|
- Физическая папка на диске будет переименована.
|
||||||
- Пути ко всем уже скачанным файлам обновятся в БД.
|
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||||
- Дозагрузка новых глав продолжится в переименованную папку.
|
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/static/logo.png
Normal file
BIN
frontend/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 477 KiB |
898
src/api.py
898
src/api.py
File diff suppressed because it is too large
Load Diff
36
src/auth.py
Normal file
36
src/auth.py
Normal 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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
111
src/cli.py
111
src/cli.py
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
|
|||||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||||
|
try:
|
||||||
if pil_images:
|
if pil_images:
|
||||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||||
|
finally:
|
||||||
|
for img in pil_images:
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
|
||||||
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
|
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
|
||||||
|
|||||||
668
src/scraper.py
668
src/scraper.py
@@ -1,665 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Парсер readmanga.ru: список глав и URL/байты изображений внутри главы.
|
Обратно-совместимый shim: делегирует вызовы ReadmangaSource.
|
||||||
|
Не используйте напрямую в новом коде — используйте src.sources.registry.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
from .sources.base import Chapter, MangaInfo # noqa: F401 — реэкспорт для импортёров
|
||||||
import re
|
from .sources.readmanga import ReadmangaSource
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
_instance = ReadmangaSource()
|
||||||
from playwright.async_api import Page
|
|
||||||
|
|
||||||
from .browser import BrowserManager
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
async def get_manga_info(page, url):
|
||||||
# Модели данных
|
return await _instance.get_manga_info(page, url)
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Chapter:
|
|
||||||
title: str
|
|
||||||
url: str
|
|
||||||
number: float = 0.0
|
|
||||||
volume: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
async def get_chapter_images_and_download(page, chapter_url, dest_dir,
|
||||||
class MangaInfo:
|
manga_url=None, on_page=None):
|
||||||
title: str
|
return await _instance.get_chapter_images_and_download(
|
||||||
url: str
|
page, chapter_url, dest_dir, manga_url=manga_url, on_page=on_page
|
||||||
chapters: list[Chapter] = field(default_factory=list)
|
|
||||||
pub_status: str = "unknown" # completed / ongoing / unknown
|
|
||||||
title_ru: str = "" # Только русский тайтл (для папки)
|
|
||||||
title_full: str = "" # Полный тайтл как на странице
|
|
||||||
description: str = "" # Описание/синопсис
|
|
||||||
genres: list[str] = field(default_factory=list) # Жанры
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Страница манги — список глав
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_manga_info(page: Page, url: str) -> Optional[MangaInfo]:
|
|
||||||
"""Открывает страницу манги и возвращает список всех глав."""
|
|
||||||
logger.info("Загружаем страницу манги: {}", url)
|
|
||||||
ok = await _navigate(page, url)
|
|
||||||
if not ok:
|
|
||||||
return None
|
|
||||||
|
|
||||||
title_full = await page.title()
|
|
||||||
title_full = re.sub(r"\s*[-–|].*$", "", title_full).strip()
|
|
||||||
|
|
||||||
# Пробуем взять русский тайтл напрямую из DOM
|
|
||||||
title_ru = await _extract_ru_title_from_dom(page)
|
|
||||||
if not title_ru:
|
|
||||||
title_ru = _parse_ru_title(title_full)
|
|
||||||
|
|
||||||
logger.info("Манга: {} | ru: {}", title_full, title_ru)
|
|
||||||
|
|
||||||
pub_status = await _extract_pub_status(page)
|
|
||||||
logger.info("Статус выпуска: {}", pub_status)
|
|
||||||
|
|
||||||
description = await _extract_description(page)
|
|
||||||
genres = await _extract_genres(page)
|
|
||||||
|
|
||||||
await _expand_chapters(page)
|
|
||||||
chapters = await _extract_chapters(page)
|
|
||||||
if not chapters:
|
|
||||||
chapters = await _extract_chapters_alt(page)
|
|
||||||
|
|
||||||
logger.info("Найдено глав: {}", len(chapters))
|
|
||||||
return MangaInfo(
|
|
||||||
title=title_ru or title_full,
|
|
||||||
url=url,
|
|
||||||
chapters=chapters,
|
|
||||||
pub_status=pub_status,
|
|
||||||
title_ru=title_ru,
|
|
||||||
title_full=title_full,
|
|
||||||
description=description,
|
|
||||||
genres=genres,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _extract_ru_title_from_dom(page: Page) -> str:
|
|
||||||
"""Ищет русский тайтл в структуре страницы readmanga."""
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
// readmanga: основной тайтл в span.name внутри .names
|
|
||||||
const selectors = [
|
|
||||||
'.names .name',
|
|
||||||
'h1.manga-title',
|
|
||||||
'h1 .name',
|
|
||||||
'.name-block .name',
|
|
||||||
];
|
|
||||||
for (const sel of selectors) {
|
|
||||||
const el = document.querySelector(sel);
|
|
||||||
if (el && el.textContent.trim()) return el.textContent.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return (result or "").strip()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ru_title(full_title: str) -> str:
|
|
||||||
"""Извлекает русский тайтл из полной строки тайтла.
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
'Манга Режим — АД. Хардкорный геймер ... (Hellmode)' → 'Режим — АД. Хардкорный геймер ...'
|
|
||||||
'Манга Магическая битва (Sorcery Fight) Гэгэ онлайн' → 'Магическая битва'
|
|
||||||
'Авантюрист Monster Eater Adventurer' → 'Авантюрист'
|
|
||||||
"""
|
|
||||||
t = full_title.strip()
|
|
||||||
# Убираем префикс "Манга "
|
|
||||||
t = re.sub(r'^Манга\s+', '', t).strip()
|
|
||||||
# Берём только до первой скобки (начало английского тайтла)
|
|
||||||
t = re.split(r'\s*[\(\[]', t)[0].strip()
|
|
||||||
# Убираем суффикс " онлайн"
|
|
||||||
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
|
|
||||||
|
|
||||||
# Обрезаем хвост из латинских слов.
|
|
||||||
# Правило: стоп только на токене содержащем латиницу (a-zA-Z).
|
|
||||||
# Пунктуация между кириллическими словами (—, –, ., :, !) — сохраняем.
|
|
||||||
words = t.split()
|
|
||||||
result = []
|
|
||||||
for w in words:
|
|
||||||
if re.search(r'[а-яёА-ЯЁ]', w):
|
|
||||||
result.append(w)
|
|
||||||
elif re.search(r'[a-zA-Z]', w):
|
|
||||||
# Первое латинское слово после кириллических — обрезаем здесь
|
|
||||||
if result:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Чисто пунктуационный токен (—, –, ., :, …)
|
|
||||||
# Добавляем только если уже есть кириллические слова (связка внутри)
|
|
||||||
if result:
|
|
||||||
result.append(w)
|
|
||||||
|
|
||||||
# Убираем висячую пунктуацию в конце (если последнее слово — не кириллица)
|
|
||||||
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
|
|
||||||
result.pop()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
t = ' '.join(result)
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_pub_status(page: Page) -> str:
|
|
||||||
"""Извлекает статус выпуска: completed / ongoing / unknown."""
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
// readmanga хранит статус в .elem_status .value или похожих блоках
|
|
||||||
const statusSelectors = [
|
|
||||||
'.elem_status .value',
|
|
||||||
'.manga-info .status',
|
|
||||||
'[class*="status"] .value',
|
|
||||||
'.property .status',
|
|
||||||
];
|
|
||||||
for (const sel of statusSelectors) {
|
|
||||||
const el = document.querySelector(sel);
|
|
||||||
if (el) {
|
|
||||||
const t = el.textContent.toLowerCase();
|
|
||||||
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
|
||||||
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: сканируем весь текст страницы
|
|
||||||
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
|
|
||||||
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
|
|
||||||
if (bodyText.includes('продолжается')) return 'ongoing';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return result or "unknown"
|
|
||||||
except Exception:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_description(page: Page) -> str:
|
|
||||||
"""Извлекает описание/синопсис манги."""
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
const selectors = [
|
|
||||||
'.manga-description',
|
|
||||||
'.elem_descr .value',
|
|
||||||
'#tab-description .description-text',
|
|
||||||
'.description',
|
|
||||||
'[itemprop="description"]',
|
|
||||||
];
|
|
||||||
for (const sel of selectors) {
|
|
||||||
const el = document.querySelector(sel);
|
|
||||||
if (el && el.textContent.trim()) return el.textContent.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return (result or "").strip()[:2000] # обрезаем до 2000 символов
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_genres(page: Page) -> list[str]:
|
|
||||||
"""Извлекает список жанров манги."""
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
const selectors = [
|
|
||||||
'.elem_genre .value a',
|
|
||||||
'.genres a',
|
|
||||||
'[itemprop="genre"]',
|
|
||||||
'.genre-list a',
|
|
||||||
];
|
|
||||||
for (const sel of selectors) {
|
|
||||||
const els = document.querySelectorAll(sel);
|
|
||||||
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return result or []
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _navigate(page: Page, url: str, retries: int = 3,
|
|
||||||
referer: str | None = None) -> bool:
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
if referer is None:
|
|
||||||
p = urlparse(url)
|
|
||||||
referer = f"{p.scheme}://{p.netloc}/"
|
|
||||||
for attempt in range(1, retries + 1):
|
|
||||||
try:
|
|
||||||
resp = await page.goto(url, wait_until="domcontentloaded",
|
|
||||||
timeout=60_000, referer=referer)
|
|
||||||
if resp and resp.status >= 400:
|
|
||||||
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
|
|
||||||
await asyncio.sleep(3 * attempt)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=10_000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
|
|
||||||
await asyncio.sleep(3 * attempt)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _expand_chapters(page: Page):
|
|
||||||
for sel in ["a.chapter-link.all", "button:has-text('Все главы')",
|
|
||||||
"a:has-text('Все главы')"]:
|
|
||||||
try:
|
|
||||||
el = page.locator(sel).first
|
|
||||||
if await el.is_visible(timeout=2000):
|
|
||||||
await el.click()
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=10_000)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_chapters(page: Page) -> list[Chapter]:
|
|
||||||
"""Основной парсер: #chapters-list → tr.item-row → td[data-num] a.chapter-link"""
|
|
||||||
rows = await page.query_selector_all("#chapters-list tr.item-row")
|
|
||||||
chapters = []
|
|
||||||
for row in rows:
|
|
||||||
link = await row.query_selector("td[class*='item-title'] a")
|
|
||||||
if not link:
|
|
||||||
continue
|
|
||||||
href = await link.get_attribute("href") or ""
|
|
||||||
text = (await link.inner_text()).strip()
|
|
||||||
if not href:
|
|
||||||
continue
|
|
||||||
td = await row.query_selector("td[data-num]")
|
|
||||||
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
|
|
||||||
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
|
|
||||||
number = num_raw / 10.0
|
|
||||||
full_url = href if href.startswith("http") else _base_url(page.url) + href
|
|
||||||
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
|
|
||||||
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
|
|
||||||
.filter(x => x.href && x.text);
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return [Chapter(title=x["text"], url=x["href"],
|
|
||||||
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
|
|
||||||
for x in result]
|
|
||||||
|
|
||||||
|
|
||||||
def _base_url(url: str) -> str:
|
|
||||||
m = re.match(r"(https?://[^/]+)", url)
|
|
||||||
return m.group(1) if m else "https://readmanga.ru"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_num(text: str) -> float:
|
|
||||||
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
|
|
||||||
return float(m.group()) if m else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_vol(text: str) -> int:
|
|
||||||
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
|
|
||||||
return int(m.group(1)) if m else 0
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Страница главы — получение URL изображений
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _extract_images_from_js(page: Page) -> list[str]:
|
|
||||||
"""
|
|
||||||
Извлекает URL из rm_h.readerInit(chapterInfo, [[base, '', path, w, h], ...]).
|
|
||||||
Считает скобки для точного захвата массива.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
for (const s of document.querySelectorAll('script')) {
|
|
||||||
const text = s.textContent || '';
|
|
||||||
const mi = text.indexOf('readerInit');
|
|
||||||
if (mi === -1) continue;
|
|
||||||
const ai = text.indexOf('[', mi);
|
|
||||||
if (ai === -1) continue;
|
|
||||||
let depth = 0, end = -1;
|
|
||||||
for (let i = ai; i < text.length; i++) {
|
|
||||||
if (text[i] === '[') depth++;
|
|
||||||
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
|
|
||||||
}
|
|
||||||
if (end === -1) continue;
|
|
||||||
try {
|
|
||||||
const arr = eval(text.slice(ai, end));
|
|
||||||
if (Array.isArray(arr) && arr.length)
|
|
||||||
return arr.map(item => Array.isArray(item) && item.length >= 3
|
|
||||||
? item[0] + item[2] : null).filter(Boolean);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
if result:
|
|
||||||
logger.debug("JS readerInit нашёл {} изображений", len(result))
|
|
||||||
return result or []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("JS-метод не сработал: {}", e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_images_from_dom(page: Page) -> list[str]:
|
|
||||||
try:
|
|
||||||
result = await page.evaluate("""
|
|
||||||
() => {
|
|
||||||
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
|
|
||||||
const found = Array.from(document.querySelectorAll(sel));
|
|
||||||
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
return result or []
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _get_ext(url: str) -> str:
|
|
||||||
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
ext = m.group(1).lower()
|
|
||||||
return ".jpg" if ext == "jpeg" else f".{ext}"
|
|
||||||
return ".jpg"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Скачивание главы
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_chapter_images_and_download(
|
|
||||||
page: Page,
|
|
||||||
chapter_url: str,
|
|
||||||
dest_dir: Path,
|
|
||||||
manga_url: str | None = None,
|
|
||||||
on_page: object = None,
|
|
||||||
) -> list[Path]:
|
|
||||||
"""
|
|
||||||
1. Открывает страницу главы (устанавливает DDoS-Guard cookies для CDN).
|
|
||||||
2. Извлекает список URL из readerInit.
|
|
||||||
3. Перехватывает img-запросы через page.route() + route.fetch()
|
|
||||||
(браузерный стек — правильные Sec-Fetch-* заголовки, cookies).
|
|
||||||
4. Пролистывает читалку клавишей ArrowRight чтобы загрузить все страницы.
|
|
||||||
5. Retry для страниц с timeout через JS fetch.
|
|
||||||
"""
|
|
||||||
t_start = time.monotonic()
|
|
||||||
ch_id = chapter_url.split("/")[-1] # короткий идентификатор для логов
|
|
||||||
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(chapter_url)
|
|
||||||
parts = parsed.path.strip("/").split("/")
|
|
||||||
manga_slug = parts[0] if parts else ""
|
|
||||||
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
|
|
||||||
|
|
||||||
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def _base(u: str) -> str:
|
|
||||||
return u.split("?")[0]
|
|
||||||
|
|
||||||
# Баннеры/рекламные изображения — игнорируем без логирования
|
|
||||||
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
|
|
||||||
|
|
||||||
def _is_manga_image(url: str) -> bool:
|
|
||||||
base = _base(url)
|
|
||||||
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
|
|
||||||
return False
|
|
||||||
if "resrmr." in url or "/static/" in url:
|
|
||||||
return False
|
|
||||||
return bool(re.search(r"one-way\.work|staticfa\.|rm\.one-way|cdnmanga|reimg", url, re.I))
|
|
||||||
|
|
||||||
captured: dict[str, bytes] = {} # base_url → bytes
|
|
||||||
route_errors: dict[str, str] = {} # base_url → текст ошибки
|
|
||||||
route_statuses: dict[str, int] = {} # base_url → HTTP status (не 200/206)
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def route_handler(route, request):
|
|
||||||
url = request.url
|
|
||||||
base = _base(url)
|
|
||||||
if not _is_manga_image(url):
|
|
||||||
await route.continue_()
|
|
||||||
return
|
|
||||||
if BANNER_RE.search(base):
|
|
||||||
await route.continue_()
|
|
||||||
return
|
|
||||||
async with lock:
|
|
||||||
already = base in captured
|
|
||||||
if already:
|
|
||||||
await route.continue_()
|
|
||||||
return
|
|
||||||
fname = base.split("/")[-1]
|
|
||||||
try:
|
|
||||||
response = await route.fetch()
|
|
||||||
status = response.status
|
|
||||||
body = await response.body()
|
|
||||||
if body and len(body) > 500 and status in (200, 206):
|
|
||||||
async with lock:
|
|
||||||
if base not in captured:
|
|
||||||
captured[base] = body
|
|
||||||
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
|
|
||||||
if on_page:
|
|
||||||
try:
|
|
||||||
asyncio.ensure_future(on_page(0, 0))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
async with lock:
|
|
||||||
route_statuses[base] = status
|
|
||||||
if status not in (200, 206):
|
|
||||||
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
|
|
||||||
ch_id, status, fname, base[-70:])
|
|
||||||
else:
|
|
||||||
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
|
|
||||||
ch_id, len(body), fname)
|
|
||||||
await route.fulfill(response=response)
|
|
||||||
except Exception as e:
|
|
||||||
err = str(e)
|
|
||||||
async with lock:
|
|
||||||
route_errors[base] = err
|
|
||||||
is_timeout = "timeout" in err.lower()
|
|
||||||
level = logger.warning if is_timeout else logger.warning
|
|
||||||
level("[{}] route.fetch {} '{}': {}",
|
|
||||||
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
|
|
||||||
try:
|
|
||||||
await route.continue_()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await page.route("**/*", route_handler)
|
|
||||||
|
|
||||||
# 1. Открываем главу
|
|
||||||
ok = await _navigate(page, load_url, referer=referer)
|
|
||||||
if not ok:
|
|
||||||
await page.unroute("**/*", route_handler)
|
|
||||||
logger.error("[{}] Не удалось открыть главу после всех retry: {}", ch_id, chapter_url)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 2. Ждём readerInit
|
|
||||||
try:
|
|
||||||
await page.wait_for_function(
|
|
||||||
"() => Array.from(document.querySelectorAll('script'))"
|
|
||||||
".some(s => s.textContent.includes('readerInit'))",
|
|
||||||
timeout=15_000,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[{}] readerInit не появился за 15с ({}). "
|
|
||||||
"Продолжаем через DOM-fallback.", ch_id, str(e)[:80])
|
|
||||||
|
|
||||||
# 3. Извлекаем список URL
|
|
||||||
image_urls = await _extract_images_from_js(page)
|
|
||||||
if not image_urls:
|
|
||||||
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
|
|
||||||
image_urls = await _extract_images_from_dom(page)
|
|
||||||
if not image_urls:
|
|
||||||
await page.unroute("**/*", route_handler)
|
|
||||||
try:
|
|
||||||
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
|
||||||
except Exception:
|
|
||||||
page_info = "?"
|
|
||||||
logger.error("[{}] Список изображений пуст. Текущая страница: {}", ch_id, page_info)
|
|
||||||
return []
|
|
||||||
|
|
||||||
logger.info("[{}] Найдено изображений: {}", ch_id, len(image_urls))
|
|
||||||
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
|
|
||||||
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
|
||||||
total = len(image_urls)
|
|
||||||
|
|
||||||
def _count_matched() -> int:
|
|
||||||
count = 0
|
|
||||||
for base_url in captured:
|
|
||||||
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
# 4. Пролистываем читалку
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
stall_count = 0
|
|
||||||
prev_done = -1
|
|
||||||
for i in range(total + 20):
|
|
||||||
done = _count_matched()
|
|
||||||
if done >= total:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
await page.keyboard.press("ArrowRight")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
|
|
||||||
break
|
|
||||||
if i % 20 == 19:
|
|
||||||
done = _count_matched()
|
|
||||||
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
|
|
||||||
if done == prev_done:
|
|
||||||
stall_count += 1
|
|
||||||
if stall_count >= 3:
|
|
||||||
logger.warning("[{}] Прогресс завис ({}/{}) после {} листаний — прерываем",
|
|
||||||
ch_id, done, total, i + 1)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
stall_count = 0
|
|
||||||
prev_done = done
|
|
||||||
|
|
||||||
# Финальное ожидание
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
|
|
||||||
# 5. Retry для страниц с timeout через браузерный JS fetch
|
|
||||||
async with lock:
|
|
||||||
timeout_bases = [u for u, e in route_errors.items()
|
|
||||||
if "timeout" in e.lower() and u not in captured]
|
|
||||||
if timeout_bases:
|
|
||||||
logger.info("[{}] Retry {} страниц с timeout через JS fetch...",
|
|
||||||
ch_id, len(timeout_bases))
|
|
||||||
for retry_base in timeout_bases:
|
|
||||||
if retry_base in captured:
|
|
||||||
continue
|
|
||||||
fname = retry_base.split("/")[-1]
|
|
||||||
try:
|
|
||||||
data_b64 = await page.evaluate("""async (url) => {
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, {credentials: 'include'});
|
|
||||||
if (!r.ok) return null;
|
|
||||||
const buf = await r.arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let bin = '';
|
|
||||||
for (let b of bytes) bin += String.fromCharCode(b);
|
|
||||||
return btoa(bin);
|
|
||||||
} catch(e) { return null; }
|
|
||||||
}""", retry_base)
|
|
||||||
if data_b64:
|
|
||||||
import base64
|
|
||||||
body = base64.b64decode(data_b64)
|
|
||||||
if len(body) > 500:
|
|
||||||
async with lock:
|
|
||||||
captured[retry_base] = body
|
|
||||||
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
|
||||||
else:
|
|
||||||
logger.warning("[{}] Retry вернул {} байт для '{}' — игнорируем",
|
|
||||||
ch_id, len(body), fname)
|
|
||||||
else:
|
|
||||||
logger.warning("[{}] Retry вернул null для '{}' | {}",
|
|
||||||
ch_id, fname, retry_base[-70:])
|
|
||||||
except Exception as e2:
|
|
||||||
logger.warning("[{}] Retry JS ошибка для '{}': {}", ch_id, fname, e2)
|
|
||||||
|
|
||||||
await page.unroute("**/*", route_handler)
|
|
||||||
|
|
||||||
done = _count_matched()
|
|
||||||
elapsed = time.monotonic() - t_start
|
|
||||||
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
|
||||||
|
|
||||||
# 6. Сохраняем в правильном порядке
|
|
||||||
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
|
||||||
|
|
||||||
paths: dict[int, Path] = {}
|
|
||||||
unmatched_other: list[str] = []
|
|
||||||
for base_url, body in captured.items():
|
|
||||||
idx = url_to_idx.get(base_url)
|
|
||||||
if idx is None:
|
|
||||||
fname = base_url.split("/")[-1]
|
|
||||||
idx = filename_to_idx.get(fname)
|
|
||||||
if idx is None:
|
|
||||||
if not BANNER_RE.search(base_url):
|
|
||||||
unmatched_other.append(base_url.split("/")[-1])
|
|
||||||
continue
|
|
||||||
ext = _get_ext(base_url)
|
|
||||||
p = dest_dir / f"{idx:04d}{ext}"
|
|
||||||
p.write_bytes(body)
|
|
||||||
paths[idx] = p
|
|
||||||
|
|
||||||
if unmatched_other:
|
|
||||||
logger.debug("[{}] Перехвачено, но не совпало с readerInit ({}): {}",
|
|
||||||
ch_id, len(unmatched_other), unmatched_other)
|
|
||||||
|
|
||||||
# 7. Итоговый отчёт по пропущенным страницам
|
|
||||||
missing_idxs = [i for i in range(total) if i not in paths]
|
|
||||||
if missing_idxs:
|
|
||||||
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
|
|
||||||
missing_full = [_base(image_urls[i]) for i in missing_idxs]
|
|
||||||
|
|
||||||
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
|
|
||||||
if missing_full[j] in route_errors
|
|
||||||
and "timeout" in route_errors[missing_full[j]].lower()]
|
|
||||||
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
|
|
||||||
for j, i in enumerate(missing_idxs)
|
|
||||||
if missing_full[j] in route_statuses]
|
|
||||||
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
|
|
||||||
if missing_full[j] not in route_errors
|
|
||||||
and missing_full[j] not in route_statuses]
|
|
||||||
|
|
||||||
reasons = []
|
|
||||||
if timeout_miss:
|
|
||||||
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
|
|
||||||
if http_miss:
|
|
||||||
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
|
|
||||||
if unrcv:
|
|
||||||
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
|
|
||||||
ch_id, len(missing_idxs), total,
|
|
||||||
[i + 1 for i in missing_idxs],
|
|
||||||
" | ".join(reasons) if reasons else "неизвестно",
|
|
||||||
)
|
|
||||||
logger.debug("[{}] Полные URL пропущенных: {}", ch_id, missing_full)
|
|
||||||
|
|
||||||
return [paths[i] for i in sorted(paths.keys())]
|
|
||||||
|
|
||||||
|
|||||||
74
src/sources/__init__.py
Normal file
74
src/sources/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Реестр источников манги.
|
||||||
|
|
||||||
|
Для добавления нового источника:
|
||||||
|
1. Создать файл src/sources/mysource.py с классом, реализующим MangaSourceProtocol
|
||||||
|
2. Импортировать его здесь и добавить в список SOURCES
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import MangaSourceProtocol
|
||||||
|
from .readmanga import ReadmangaSource
|
||||||
|
|
||||||
|
# ── Регистрация источников ─────────────────────
|
||||||
|
# Добавьте новые источники сюда:
|
||||||
|
SOURCES: list = [
|
||||||
|
ReadmangaSource(),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Быстрый поиск по slug
|
||||||
|
_BY_SLUG: dict[str, object] = {s.slug: s for s in SOURCES}
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRegistry:
|
||||||
|
"""Реестр источников. Источники определяются только в коде."""
|
||||||
|
|
||||||
|
def get_by_slug(self, slug: str) -> Optional[object]:
|
||||||
|
return _BY_SLUG.get(slug)
|
||||||
|
|
||||||
|
def get_by_db_id(self, source_id: int, db) -> Optional[object]:
|
||||||
|
"""Резолвит адаптер через БД: source_id → slug → экземпляр."""
|
||||||
|
row = db.get_source_by_id(source_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _BY_SLUG.get(row["slug"])
|
||||||
|
|
||||||
|
def all_sources(self) -> list:
|
||||||
|
return list(SOURCES)
|
||||||
|
|
||||||
|
def all_slugs(self) -> list[str]:
|
||||||
|
return [s.slug for s in SOURCES]
|
||||||
|
|
||||||
|
|
||||||
|
registry = SourceRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_for_url(url: str, db) -> Optional[object]:
|
||||||
|
"""
|
||||||
|
Определяет источник по домену URL.
|
||||||
|
Ищет домен в таблице source_domains → возвращает адаптер.
|
||||||
|
Если домен не зарегистрирован — возвращает None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
row = db.get_source_by_domain(domain)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _BY_SLUG.get(row["slug"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_domain(url: str) -> str:
|
||||||
|
"""Извлекает домен без www."""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
58
src/sources/base.py
Normal file
58
src/sources/base.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Базовые модели данных и Protocol-интерфейс для источников манги.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Модели данных (общие для всех источников)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chapter:
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
number: float = 0.0
|
||||||
|
volume: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MangaInfo:
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
chapters: list[Chapter] = field(default_factory=list)
|
||||||
|
pub_status: str = "unknown" # completed / ongoing / unknown
|
||||||
|
title_ru: str = ""
|
||||||
|
title_full: str = ""
|
||||||
|
description: str = ""
|
||||||
|
genres: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Интерфейс источника
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MangaSourceProtocol(Protocol):
|
||||||
|
slug: str # уникальный код источника в коде ("readmanga")
|
||||||
|
display_name: str # название для UI ("ReadManga")
|
||||||
|
|
||||||
|
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
|
||||||
|
"""Возвращает информацию о манге и список глав."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_chapter_images_and_download(
|
||||||
|
self,
|
||||||
|
page: Page,
|
||||||
|
chapter_url: str,
|
||||||
|
dest_dir: Path,
|
||||||
|
manga_url: Optional[str] = None,
|
||||||
|
on_page: object = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Скачивает страницы главы в dest_dir и возвращает список путей."""
|
||||||
|
...
|
||||||
|
|
||||||
586
src/sources/readmanga.py
Normal file
586
src/sources/readmanga.py
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
"""
|
||||||
|
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from .base import Chapter, MangaInfo
|
||||||
|
|
||||||
|
|
||||||
|
class ReadmangaSource:
|
||||||
|
slug = "readmanga"
|
||||||
|
display_name = "ReadManga"
|
||||||
|
|
||||||
|
# CDN-домены из которых принимаем картинки глав
|
||||||
|
cdn_patterns = ["one-way.work", "staticfa.", "rm.one-way", "cdnmanga", "reimg"]
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Страница манги — список глав
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
|
||||||
|
"""Открывает страницу манги и возвращает список всех глав."""
|
||||||
|
logger.info("Загружаем страницу манги: {}", url)
|
||||||
|
ok = await _navigate(page, url)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
title_full = await page.title()
|
||||||
|
title_full = re.sub(r"\s*[-–|].*$", "", title_full).strip()
|
||||||
|
|
||||||
|
title_ru = await _extract_ru_title_from_dom(page)
|
||||||
|
if not title_ru:
|
||||||
|
title_ru = _parse_ru_title(title_full)
|
||||||
|
|
||||||
|
logger.info("Манга: {} | ru: {}", title_full, title_ru)
|
||||||
|
|
||||||
|
pub_status = await _extract_pub_status(page)
|
||||||
|
logger.info("Статус выпуска: {}", pub_status)
|
||||||
|
|
||||||
|
description = await _extract_description(page)
|
||||||
|
genres = await _extract_genres(page)
|
||||||
|
|
||||||
|
await _expand_chapters(page)
|
||||||
|
chapters = await _extract_chapters(page)
|
||||||
|
if not chapters:
|
||||||
|
chapters = await _extract_chapters_alt(page)
|
||||||
|
|
||||||
|
logger.info("Найдено глав: {}", len(chapters))
|
||||||
|
return MangaInfo(
|
||||||
|
title=title_ru or title_full,
|
||||||
|
url=url,
|
||||||
|
chapters=chapters,
|
||||||
|
pub_status=pub_status,
|
||||||
|
title_ru=title_ru,
|
||||||
|
title_full=title_full,
|
||||||
|
description=description,
|
||||||
|
genres=genres,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Скачивание главы
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_chapter_images_and_download(
|
||||||
|
self,
|
||||||
|
page: Page,
|
||||||
|
chapter_url: str,
|
||||||
|
dest_dir: Path,
|
||||||
|
manga_url: Optional[str] = None,
|
||||||
|
on_page: object = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""
|
||||||
|
1. Открывает страницу главы.
|
||||||
|
2. Извлекает список URL из readerInit.
|
||||||
|
3. Перехватывает img-запросы через page.route().
|
||||||
|
4. Пролистывает читалку клавишей ArrowRight.
|
||||||
|
5. Retry для страниц с timeout через JS fetch.
|
||||||
|
"""
|
||||||
|
cdn_patterns = self.cdn_patterns
|
||||||
|
t_start = time.monotonic()
|
||||||
|
ch_id = chapter_url.split("/")[-1]
|
||||||
|
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
||||||
|
|
||||||
|
parsed = urlparse(chapter_url)
|
||||||
|
parts = parsed.path.strip("/").split("/")
|
||||||
|
manga_slug = parts[0] if parts else ""
|
||||||
|
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
|
||||||
|
|
||||||
|
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _base(u: str) -> str:
|
||||||
|
return u.split("?")[0]
|
||||||
|
|
||||||
|
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
|
||||||
|
|
||||||
|
def _is_manga_image(url: str) -> bool:
|
||||||
|
base = _base(url)
|
||||||
|
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
|
||||||
|
return False
|
||||||
|
if "resrmr." in url or "/static/" in url:
|
||||||
|
return False
|
||||||
|
pattern = "|".join(re.escape(p) for p in cdn_patterns)
|
||||||
|
return bool(re.search(pattern, url, re.I))
|
||||||
|
|
||||||
|
captured: dict[str, bytes] = {}
|
||||||
|
route_errors: dict[str, str] = {}
|
||||||
|
route_statuses: dict[str, int] = {}
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def route_handler(route, request):
|
||||||
|
url = request.url
|
||||||
|
base = _base(url)
|
||||||
|
if not _is_manga_image(url):
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
if BANNER_RE.search(base):
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
async with lock:
|
||||||
|
already = base in captured
|
||||||
|
if already:
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
fname = base.split("/")[-1]
|
||||||
|
try:
|
||||||
|
response = await route.fetch()
|
||||||
|
status = response.status
|
||||||
|
body = await response.body()
|
||||||
|
if body and len(body) > 500 and status in (200, 206):
|
||||||
|
async with lock:
|
||||||
|
if base not in captured:
|
||||||
|
captured[base] = body
|
||||||
|
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
|
||||||
|
if on_page:
|
||||||
|
try:
|
||||||
|
asyncio.ensure_future(on_page(0, 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
async with lock:
|
||||||
|
route_statuses[base] = status
|
||||||
|
if status not in (200, 206):
|
||||||
|
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
|
||||||
|
ch_id, status, fname, base[-70:])
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
|
||||||
|
ch_id, len(body), fname)
|
||||||
|
await route.fulfill(response=response)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e)
|
||||||
|
async with lock:
|
||||||
|
route_errors[base] = err
|
||||||
|
is_timeout = "timeout" in err.lower()
|
||||||
|
logger.warning("[{}] route.fetch {} '{}': {}",
|
||||||
|
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
|
||||||
|
try:
|
||||||
|
await route.continue_()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await page.route("**/*", route_handler)
|
||||||
|
|
||||||
|
ok = await _navigate(page, load_url, referer=referer)
|
||||||
|
if not ok:
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(
|
||||||
|
"() => Array.from(document.querySelectorAll('script'))"
|
||||||
|
".some(s => s.textContent.includes('readerInit'))",
|
||||||
|
timeout=15_000,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[{}] readerInit не появился за 15с ({}). DOM-fallback.", ch_id, str(e)[:80])
|
||||||
|
|
||||||
|
image_urls = await _extract_images_from_js(page)
|
||||||
|
if not image_urls:
|
||||||
|
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
|
||||||
|
image_urls = await _extract_images_from_dom(page)
|
||||||
|
if not image_urls:
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
try:
|
||||||
|
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
||||||
|
except Exception:
|
||||||
|
page_info = "?"
|
||||||
|
logger.error("[{}] Список изображений пуст. Страница: {}", ch_id, page_info)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("[{}] Найдено изображений: {}", ch_id, len(image_urls))
|
||||||
|
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
|
||||||
|
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
||||||
|
total = len(image_urls)
|
||||||
|
|
||||||
|
def _count_matched() -> int:
|
||||||
|
count = 0
|
||||||
|
for base_url in captured:
|
||||||
|
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
stall_count = 0
|
||||||
|
prev_done = -1
|
||||||
|
for i in range(total + 20):
|
||||||
|
done = _count_matched()
|
||||||
|
if done >= total:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await page.keyboard.press("ArrowRight")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
|
||||||
|
break
|
||||||
|
if i % 20 == 19:
|
||||||
|
done = _count_matched()
|
||||||
|
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
|
||||||
|
if done == prev_done:
|
||||||
|
stall_count += 1
|
||||||
|
if stall_count >= 3:
|
||||||
|
logger.warning("[{}] Прогресс завис ({}/{}) — прерываем", ch_id, done, total)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
stall_count = 0
|
||||||
|
prev_done = done
|
||||||
|
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# Retry timeout через JS fetch
|
||||||
|
async with lock:
|
||||||
|
timeout_bases = [u for u, e in route_errors.items()
|
||||||
|
if "timeout" in e.lower() and u not in captured]
|
||||||
|
if timeout_bases:
|
||||||
|
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
|
||||||
|
for retry_base in timeout_bases:
|
||||||
|
if retry_base in captured:
|
||||||
|
continue
|
||||||
|
fname = retry_base.split("/")[-1]
|
||||||
|
try:
|
||||||
|
data_b64 = await page.evaluate("""async (url) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {credentials: 'include'});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const buf = await r.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let bin = '';
|
||||||
|
for (let b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}""", retry_base)
|
||||||
|
if data_b64:
|
||||||
|
body = base64.b64decode(data_b64)
|
||||||
|
if len(body) > 500:
|
||||||
|
async with lock:
|
||||||
|
captured[retry_base] = body
|
||||||
|
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Retry вернул {} байт — игнорируем", ch_id, len(body))
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
|
||||||
|
except Exception as e2:
|
||||||
|
logger.warning("[{}] Retry JS ошибка '{}': {}", ch_id, fname, e2)
|
||||||
|
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
|
||||||
|
done = _count_matched()
|
||||||
|
elapsed = time.monotonic() - t_start
|
||||||
|
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
||||||
|
|
||||||
|
paths: dict[int, Path] = {}
|
||||||
|
unmatched_other: list[str] = []
|
||||||
|
for base_url, body in captured.items():
|
||||||
|
idx = url_to_idx.get(base_url)
|
||||||
|
if idx is None:
|
||||||
|
fname = base_url.split("/")[-1]
|
||||||
|
idx = filename_to_idx.get(fname)
|
||||||
|
if idx is None:
|
||||||
|
if not BANNER_RE.search(base_url):
|
||||||
|
unmatched_other.append(base_url.split("/")[-1])
|
||||||
|
continue
|
||||||
|
ext = _get_ext(base_url)
|
||||||
|
p = dest_dir / f"{idx:04d}{ext}"
|
||||||
|
p.write_bytes(body)
|
||||||
|
paths[idx] = p
|
||||||
|
|
||||||
|
if unmatched_other:
|
||||||
|
logger.debug("[{}] Не совпало с readerInit ({}): {}", ch_id, len(unmatched_other), unmatched_other)
|
||||||
|
|
||||||
|
missing_idxs = [i for i in range(total) if i not in paths]
|
||||||
|
if missing_idxs:
|
||||||
|
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
|
||||||
|
missing_full = [_base(image_urls[i]) for i in missing_idxs]
|
||||||
|
|
||||||
|
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] in route_errors
|
||||||
|
and "timeout" in route_errors[missing_full[j]].lower()]
|
||||||
|
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
|
||||||
|
for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] in route_statuses]
|
||||||
|
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] not in route_errors
|
||||||
|
and missing_full[j] not in route_statuses]
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
if timeout_miss:
|
||||||
|
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
|
||||||
|
if http_miss:
|
||||||
|
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
|
||||||
|
if unrcv:
|
||||||
|
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
|
||||||
|
ch_id, len(missing_idxs), total,
|
||||||
|
[i + 1 for i in missing_idxs],
|
||||||
|
" | ".join(reasons) if reasons else "неизвестно",
|
||||||
|
)
|
||||||
|
|
||||||
|
return [paths[i] for i in sorted(paths.keys())]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Вспомогательные функции (приватные)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _navigate(page: Page, url: str, retries: int = 3,
|
||||||
|
referer: str | None = None) -> bool:
|
||||||
|
if referer is None:
|
||||||
|
p = urlparse(url)
|
||||||
|
referer = f"{p.scheme}://{p.netloc}/"
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded",
|
||||||
|
timeout=60_000, referer=referer)
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_ru_title_from_dom(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const selectors = [
|
||||||
|
'.names .name', 'h1.manga-title', 'h1 .name', '.name-block .name',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ru_title(full_title: str) -> str:
|
||||||
|
t = full_title.strip()
|
||||||
|
t = re.sub(r'^Манга\s+', '', t).strip()
|
||||||
|
t = re.split(r'\s*[\(\[]', t)[0].strip()
|
||||||
|
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
|
||||||
|
words = t.split()
|
||||||
|
result = []
|
||||||
|
for w in words:
|
||||||
|
if re.search(r'[а-яёА-ЯЁ]', w):
|
||||||
|
result.append(w)
|
||||||
|
elif re.search(r'[a-zA-Z]', w):
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if result:
|
||||||
|
result.append(w)
|
||||||
|
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
|
||||||
|
result.pop()
|
||||||
|
if result:
|
||||||
|
t = ' '.join(result)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_pub_status(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const statusSelectors = [
|
||||||
|
'.elem_status .value', '.manga-info .status',
|
||||||
|
'[class*="status"] .value', '.property .status',
|
||||||
|
];
|
||||||
|
for (const sel of statusSelectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el) {
|
||||||
|
const t = el.textContent.toLowerCase();
|
||||||
|
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
||||||
|
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
|
||||||
|
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
|
||||||
|
if (bodyText.includes('продолжается')) return 'ongoing';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or "unknown"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_description(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const selectors = [
|
||||||
|
'.manga-description', '.elem_descr .value',
|
||||||
|
'#tab-description .description-text', '.description',
|
||||||
|
'[itemprop="description"]',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()[:2000]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_genres(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const selectors = [
|
||||||
|
'.elem_genre .value a', '.genres a',
|
||||||
|
'[itemprop="genre"]', '.genre-list a',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const els = document.querySelectorAll(sel);
|
||||||
|
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _expand_chapters(page: Page):
|
||||||
|
for sel in ["a.chapter-link.all", "button:has-text('Все главы')", "a:has-text('Все главы')"]:
|
||||||
|
try:
|
||||||
|
el = page.locator(sel).first
|
||||||
|
if await el.is_visible(timeout=2000):
|
||||||
|
await el.click()
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_chapters(page: Page) -> list[Chapter]:
|
||||||
|
rows = await page.query_selector_all("#chapters-list tr.item-row")
|
||||||
|
chapters = []
|
||||||
|
for row in rows:
|
||||||
|
link = await row.query_selector("td[class*='item-title'] a")
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
href = await link.get_attribute("href") or ""
|
||||||
|
text = (await link.inner_text()).strip()
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
td = await row.query_selector("td[data-num]")
|
||||||
|
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
|
||||||
|
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
|
||||||
|
number = num_raw / 10.0
|
||||||
|
full_url = href if href.startswith("http") else _base_url(page.url) + href
|
||||||
|
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
|
||||||
|
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
|
||||||
|
.filter(x => x.href && x.text);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return [Chapter(title=x["text"], url=x["href"],
|
||||||
|
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
|
||||||
|
for x in result]
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_images_from_js(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
for (const s of document.querySelectorAll('script')) {
|
||||||
|
const text = s.textContent || '';
|
||||||
|
const mi = text.indexOf('readerInit');
|
||||||
|
if (mi === -1) continue;
|
||||||
|
const ai = text.indexOf('[', mi);
|
||||||
|
if (ai === -1) continue;
|
||||||
|
let depth = 0, end = -1;
|
||||||
|
for (let i = ai; i < text.length; i++) {
|
||||||
|
if (text[i] === '[') depth++;
|
||||||
|
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
|
||||||
|
}
|
||||||
|
if (end === -1) continue;
|
||||||
|
try {
|
||||||
|
const arr = eval(text.slice(ai, end));
|
||||||
|
if (Array.isArray(arr) && arr.length)
|
||||||
|
return arr.map(item => Array.isArray(item) && item.length >= 3
|
||||||
|
? item[0] + item[2] : null).filter(Boolean);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
if result:
|
||||||
|
logger.debug("JS readerInit нашёл {} изображений", len(result))
|
||||||
|
return result or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("JS-метод не сработал: {}", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_images_from_dom(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
|
||||||
|
const found = Array.from(document.querySelectorAll(sel));
|
||||||
|
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ext(url: str) -> str:
|
||||||
|
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
ext = m.group(1).lower()
|
||||||
|
return ".jpg" if ext == "jpeg" else f".{ext}"
|
||||||
|
return ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(url: str) -> str:
|
||||||
|
m = re.match(r"(https?://[^/]+)", url)
|
||||||
|
return m.group(1) if m else "https://readmanga.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_num(text: str) -> float:
|
||||||
|
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
|
||||||
|
return float(m.group()) if m else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vol(text: str) -> int:
|
||||||
|
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
|
||||||
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
399
src/state.py
399
src/state.py
@@ -1,14 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Хранение состояния скачивания в SQLite.
|
Хранение состояния скачивания в SQLite.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
DB_PATH = Path("/app/state/progress.db")
|
DB_PATH = Path("/app/state/progress.db")
|
||||||
|
|
||||||
|
# Домены ReadManga по умолчанию (сидинг при первом запуске)
|
||||||
|
_DEFAULT_READMANGA_DOMAINS = [
|
||||||
|
"readmanga.ru",
|
||||||
|
"readmanga.live",
|
||||||
|
"readmanga.me",
|
||||||
|
"readmanga.io",
|
||||||
|
"3.readmanga.ru",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StateDB:
|
class StateDB:
|
||||||
def __init__(self, db_path: Path = DB_PATH):
|
def __init__(self, db_path: Path = DB_PATH):
|
||||||
@@ -68,6 +79,41 @@ class StateDB:
|
|||||||
created_at TEXT
|
created_at TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sources (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
settings TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS source_domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_id INTEGER NOT NULL REFERENCES sources(id),
|
||||||
|
domain TEXT UNIQUE NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
is_env_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT,
|
||||||
|
expires_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
# Migrate old DB: add missing columns
|
# Migrate old DB: add missing columns
|
||||||
migrations = [
|
migrations = [
|
||||||
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
||||||
@@ -80,6 +126,9 @@ class StateDB:
|
|||||||
("mangas", "started_at", "TEXT"),
|
("mangas", "started_at", "TEXT"),
|
||||||
("mangas", "finished_at", "TEXT"),
|
("mangas", "finished_at", "TEXT"),
|
||||||
("mangas", "folder_name", "TEXT"),
|
("mangas", "folder_name", "TEXT"),
|
||||||
|
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
||||||
|
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
|
||||||
|
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
for table, col, typedef in migrations:
|
for table, col, typedef in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -88,17 +137,185 @@ class StateDB:
|
|||||||
pass
|
pass
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def sync_sources(self, registry) -> None:
|
||||||
|
"""
|
||||||
|
Синхронизирует таблицу sources с реестром из кода.
|
||||||
|
Вызывается при старте приложения.
|
||||||
|
При первом запуске создаёт записи и засеивает домены ReadManga.
|
||||||
|
"""
|
||||||
|
from loguru import logger
|
||||||
|
for source in registry.all_sources():
|
||||||
|
existing = self.conn.execute(
|
||||||
|
"SELECT id, display_name FROM sources WHERE slug=?", (source.slug,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO sources (slug, display_name, settings, created_at) VALUES (?,?,?,?)",
|
||||||
|
(source.slug, source.display_name, "{}", _now())
|
||||||
|
)
|
||||||
|
logger.info("Источник добавлен в БД: {} ({})", source.display_name, source.slug)
|
||||||
|
else:
|
||||||
|
if existing["display_name"] != source.display_name:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sources SET display_name=? WHERE slug=?",
|
||||||
|
(source.display_name, source.slug)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# Сидинг доменов ReadManga при первом запуске
|
||||||
|
rm = self.conn.execute("SELECT id FROM sources WHERE slug='readmanga'").fetchone()
|
||||||
|
if rm:
|
||||||
|
count = self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (rm["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
for domain in _DEFAULT_READMANGA_DOMAINS:
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
|
||||||
|
(rm["id"], domain)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS))
|
||||||
|
|
||||||
|
# Логируем источники в БД без кода (не в реестре)
|
||||||
|
known_slugs = set(registry.all_slugs())
|
||||||
|
db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()]
|
||||||
|
for slug in db_slugs:
|
||||||
|
if slug not in known_slugs:
|
||||||
|
logger.warning("Источник '{}' есть в БД, но отсутствует в реестре — манги недоступны", slug)
|
||||||
|
|
||||||
|
def migrate_manga_sources(self) -> int:
|
||||||
|
"""
|
||||||
|
Авто-миграция: проставляет source_id для манг с source_id IS NULL.
|
||||||
|
Определяет источник по домену URL через source_domains.
|
||||||
|
Возвращает количество обновлённых манг.
|
||||||
|
"""
|
||||||
|
nulls = self.conn.execute(
|
||||||
|
"SELECT url FROM mangas WHERE source_id IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
updated = 0
|
||||||
|
for row in nulls:
|
||||||
|
url = row["url"]
|
||||||
|
domain = _extract_domain(url)
|
||||||
|
source_row = self.get_source_by_domain(domain)
|
||||||
|
if source_row:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET source_id=? WHERE url=?",
|
||||||
|
(source_row["id"], url)
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
if updated:
|
||||||
|
self.conn.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
# ── Sources ───────────────────────────────────
|
||||||
|
|
||||||
|
def get_source_by_id(self, source_id: int) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_source_by_slug(self, slug: str) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_source_by_domain(self, domain: str) -> Optional[dict]:
|
||||||
|
"""Возвращает запись source по домену (через source_domains JOIN)."""
|
||||||
|
row = self.conn.execute("""
|
||||||
|
SELECT s.* FROM sources s
|
||||||
|
JOIN source_domains sd ON sd.source_id = s.id
|
||||||
|
WHERE sd.domain=?
|
||||||
|
""", (domain.lower(),)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_all_sources(self) -> list[dict]:
|
||||||
|
"""Возвращает все источники с вложенным списком доменов."""
|
||||||
|
sources = self.conn.execute("SELECT * FROM sources ORDER BY id").fetchall()
|
||||||
|
result = []
|
||||||
|
for s in sources:
|
||||||
|
s_dict = dict(s)
|
||||||
|
domains = self.conn.execute(
|
||||||
|
"SELECT domain FROM source_domains WHERE source_id=? ORDER BY domain",
|
||||||
|
(s["id"],)
|
||||||
|
).fetchall()
|
||||||
|
s_dict["domains"] = [d["domain"] for d in domains]
|
||||||
|
try:
|
||||||
|
s_dict["settings"] = json.loads(s_dict.get("settings") or "{}")
|
||||||
|
except Exception:
|
||||||
|
s_dict["settings"] = {}
|
||||||
|
result.append(s_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def add_domain(self, source_id: int, domain: str) -> bool:
|
||||||
|
"""Добавляет домен к источнику. Возвращает False если уже существует."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
|
||||||
|
(source_id, domain)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_domain(self, source_id: int, domain: str) -> bool:
|
||||||
|
"""Удаляет домен у источника. Возвращает True если удалён."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM source_domains WHERE source_id=? AND domain=?",
|
||||||
|
(source_id, domain.lower())
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def set_manga_source(self, manga_url: str, source_id: int) -> None:
|
||||||
|
"""Меняет источник у манги."""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET source_id=?, updated_at=? WHERE url=?",
|
||||||
|
(source_id, _now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def reset_failed_chapters(self, manga_url: str) -> int:
|
||||||
|
"""Сбрасывает failed и partial главы в pending. Возвращает количество."""
|
||||||
|
now = _now()
|
||||||
|
c1 = self.conn.execute(
|
||||||
|
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? "
|
||||||
|
"WHERE manga_url=? AND status='failed'",
|
||||||
|
(now, manga_url)
|
||||||
|
).rowcount
|
||||||
|
c2 = self.conn.execute(
|
||||||
|
"""UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?
|
||||||
|
WHERE manga_url=? AND status='done'
|
||||||
|
AND pages_total > 0 AND pages_done < pages_total""",
|
||||||
|
(now, manga_url)
|
||||||
|
).rowcount
|
||||||
|
self.conn.commit()
|
||||||
|
return c1 + c2
|
||||||
|
|
||||||
|
def count_mangas_by_source_domain(self, domain: str) -> int:
|
||||||
|
"""Считает манги с указанным доменом (для предупреждений в UI)."""
|
||||||
|
source = self.get_source_by_domain(domain)
|
||||||
|
if not source:
|
||||||
|
return 0
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM mangas WHERE source_id=?", (source["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
# ── Mangas ────────────────────────────────────
|
# ── Mangas ────────────────────────────────────
|
||||||
|
|
||||||
def add_manga(self, url: str, fmt: str = "cbz") -> bool:
|
def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None,
|
||||||
|
added_by: Optional[int] = None) -> bool:
|
||||||
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
||||||
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
return False
|
return False
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
INSERT INTO mangas (url, format, status, added_at, updated_at)
|
INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at)
|
||||||
VALUES (?, ?, 'queued', ?, ?)
|
VALUES (?, ?, 'queued', ?, ?, ?, ?)
|
||||||
""", (url, fmt, _now(), _now()))
|
""", (url, fmt, source_id, added_by, _now(), _now()))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -185,17 +402,21 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def increment_manga_chapters_done(self, url: str):
|
|
||||||
# Оставлен для совместимости, но не используется в воркере
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_manga(self, url: str) -> Optional[dict]:
|
def get_manga(self, url: str) -> Optional[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
WHERE m.url=?
|
||||||
|
""", (url,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def get_all_mangas(self) -> list[dict]:
|
def get_all_mangas(self) -> list[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas ORDER BY added_at DESC")
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
ORDER BY m.added_at DESC
|
||||||
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_manga_format(self, url: str) -> str:
|
def get_manga_format(self, url: str) -> str:
|
||||||
@@ -203,6 +424,57 @@ class StateDB:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return row["format"] if row else "cbz"
|
return row["format"] if row else "cbz"
|
||||||
|
|
||||||
|
def get_chapter_stats(self, manga_url: str) -> dict:
|
||||||
|
"""Returns done/failed/partial chapter counts in a single query."""
|
||||||
|
row = self.conn.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN status='done' THEN 1 END) as done,
|
||||||
|
COUNT(CASE WHEN status='failed' THEN 1 END) as failed,
|
||||||
|
COUNT(CASE WHEN status='done' AND pages_total > 0
|
||||||
|
AND pages_done < pages_total THEN 1 END) as partial
|
||||||
|
FROM chapters WHERE manga_url=?
|
||||||
|
""", (manga_url,)).fetchone()
|
||||||
|
return {"done": row[0], "failed": row[1], "partial": row[2]}
|
||||||
|
|
||||||
|
def reset_all_chapters(self, manga_url: str) -> None:
|
||||||
|
"""Resets ALL chapters to pending (used by force-redownload)."""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
|
||||||
|
" WHERE manga_url=?",
|
||||||
|
(_now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_manga_cascade(self, manga_url: str) -> None:
|
||||||
|
"""Deletes manga and all related chapters and history."""
|
||||||
|
self.conn.execute("DELETE FROM chapters WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM history WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM mangas WHERE url=?", (manga_url,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_chapter_output_paths(self, manga_url: str, old_prefix: str, new_prefix: str) -> None:
|
||||||
|
"""Replaces old_prefix with new_prefix in chapter output paths after folder rename."""
|
||||||
|
chapters = self.get_all_chapters(manga_url)
|
||||||
|
for ch in chapters:
|
||||||
|
for col in ("output_cbz", "output_pdf", "output_epub"):
|
||||||
|
p = ch.get(col)
|
||||||
|
if p and old_prefix in p:
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE chapters SET {col}=?, updated_at=? WHERE chapter_url=?",
|
||||||
|
(p.replace(old_prefix, new_prefix), _now(), ch["chapter_url"])
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_news(self, limit: int = 100) -> list[dict]:
|
||||||
|
"""Returns recently downloaded chapters for the news feed."""
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT h.*, m.title as manga_title, m.title_ru
|
||||||
|
FROM history h LEFT JOIN mangas m ON h.manga_url = m.url
|
||||||
|
WHERE h.event_type IN ('downloaded', 'auto_downloaded')
|
||||||
|
ORDER BY h.created_at DESC LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
# ── Chapters ──────────────────────────────────
|
# ── Chapters ──────────────────────────────────
|
||||||
|
|
||||||
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
||||||
@@ -226,6 +498,8 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||||
|
if fmt not in _ALLOWED_FMTS:
|
||||||
|
raise ValueError(f"Unknown format: {fmt}")
|
||||||
col = f"output_{fmt}"
|
col = f"output_{fmt}"
|
||||||
self.conn.execute(f"""
|
self.conn.execute(f"""
|
||||||
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||||
@@ -311,10 +585,109 @@ class StateDB:
|
|||||||
""")
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# ── Users ─────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user(self, username: str, hashed_password: str, role: str = "user",
|
||||||
|
is_env_admin: bool = False) -> dict:
|
||||||
|
"""Создаёт пользователя. Возвращает dict без поля password."""
|
||||||
|
self.conn.execute("""
|
||||||
|
INSERT INTO users (username, password, role, is_env_admin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (username, hashed_password, role, 1 if is_env_admin else 0, _now(), _now()))
|
||||||
|
self.conn.commit()
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT id, username, role, is_env_admin, created_at FROM users WHERE username=?",
|
||||||
|
(username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[dict]:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM users WHERE username=?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_all_users(self) -> list[dict]:
|
||||||
|
"""Возвращает всех пользователей без поля password."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT id, username, role, is_env_admin, created_at, updated_at FROM users ORDER BY id"
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def count_admins(self) -> int:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role='admin'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
def update_user(self, user_id: int, **kwargs) -> None:
|
||||||
|
"""Обновляет поля пользователя. Разрешённые поля: username, password, role."""
|
||||||
|
allowed = {"username", "password", "role"}
|
||||||
|
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
updates["updated_at"] = _now()
|
||||||
|
sets = ", ".join(f"{k}=?" for k in updates)
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE users SET {sets} WHERE id=?", [*updates.values(), user_id]
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> None:
|
||||||
|
"""Удаляет пользователя и все его сессии."""
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
|
||||||
|
self.conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── Sessions ──────────────────────────────────
|
||||||
|
|
||||||
|
def create_session(self, token: str, user_id: int, expires_at: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)",
|
||||||
|
(token, user_id, _now(), expires_at)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_session(self, token: str) -> Optional[dict]:
|
||||||
|
"""Возвращает сессию если действующая (не истекла)."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE token=? AND expires_at > ?",
|
||||||
|
(token, _now())
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_session(self, token: str) -> None:
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self) -> int:
|
||||||
|
"""Удаляет истёкшие сессии. Возвращает количество удалённых."""
|
||||||
|
cur = self.conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),))
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
||||||
return datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_domain(url: str) -> str:
|
||||||
|
"""Извлекает домен без www."""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
src/utils.py
Normal file
15
src/utils.py
Normal 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}"
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
@@ -11,9 +10,11 @@ from typing import Callable, Optional
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .browser import BrowserManager
|
from .browser import BrowserManager
|
||||||
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter
|
from .sources import registry, get_source_for_url, extract_domain
|
||||||
|
from .sources.base import Chapter, MangaInfo
|
||||||
from .exporter import export, MangaMeta
|
from .exporter import export, MangaMeta
|
||||||
from .state import StateDB
|
from .state import StateDB
|
||||||
|
from .utils import safe_name, safe_chapter_name
|
||||||
|
|
||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
|
|
||||||
@@ -21,15 +22,6 @@ OUTPUT_DIR = Path("/app/output")
|
|||||||
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(s: str) -> str:
|
|
||||||
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_chapter_name(ch: Chapter) -> str:
|
|
||||||
vol = f"v{ch.volume:02d}_" if ch.volume else ""
|
|
||||||
return f"{vol}ch{ch.number:06.1f}"
|
|
||||||
|
|
||||||
|
|
||||||
async def download_manga(
|
async def download_manga(
|
||||||
url: str,
|
url: str,
|
||||||
fmt: str = "cbz",
|
fmt: str = "cbz",
|
||||||
@@ -61,10 +53,23 @@ async def download_manga(
|
|||||||
started_ts = await db_call(db.mark_started, url)
|
started_ts = await db_call(db.mark_started, url)
|
||||||
await emit({"type": "manga_start", "url": url, "started_at": started_ts})
|
await emit({"type": "manga_start", "url": url, "started_at": started_ts})
|
||||||
|
|
||||||
|
# Резолвим источник
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
# Последний шанс: по source_id в БД
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
if manga_row and manga_row.get("source_id"):
|
||||||
|
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||||
|
if source is None:
|
||||||
|
await db_call(db.update_manga_status, url, "failed")
|
||||||
|
await emit({"type": "source_unknown", "url": url,
|
||||||
|
"error": "Источник не определён. Выберите источник в настройках манги."})
|
||||||
|
return
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
ctx, info_page = await bm.new_page()
|
ctx, info_page = await bm.new_page()
|
||||||
|
|
||||||
manga = await get_manga_info(info_page, url)
|
manga = await source.get_manga_info(info_page, url)
|
||||||
await info_page.close()
|
await info_page.close()
|
||||||
|
|
||||||
if not manga:
|
if not manga:
|
||||||
@@ -96,7 +101,7 @@ async def download_manga(
|
|||||||
_db_manga = await db_call(db.get_manga, url)
|
_db_manga = await db_call(db.get_manga, url)
|
||||||
folder_name = (
|
folder_name = (
|
||||||
(_db_manga.get("folder_name") if _db_manga else None)
|
(_db_manga.get("folder_name") if _db_manga else None)
|
||||||
or _safe_name(manga.title_ru or manga.title)
|
or safe_name(manga.title_ru or manga.title)
|
||||||
)
|
)
|
||||||
manga_dir = output_dir / folder_name
|
manga_dir = output_dir / folder_name
|
||||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -178,22 +183,23 @@ async def download_manga(
|
|||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
pages_done_count = [0]
|
pages_done = 0
|
||||||
|
|
||||||
async def on_page(page_idx: int, pages_total: int):
|
async def on_page(page_idx: int, pages_total: int):
|
||||||
pages_done_count[0] += 1
|
nonlocal pages_done
|
||||||
|
pages_done += 1
|
||||||
await db_call(db.update_chapter_pages,
|
await db_call(db.update_chapter_pages,
|
||||||
ch.url, pages_total, pages_done_count[0])
|
ch.url, pages_total, pages_done)
|
||||||
await emit({
|
await emit({
|
||||||
"type": "page_done",
|
"type": "page_done",
|
||||||
"url": url,
|
"url": url,
|
||||||
"chapter_url": ch.url,
|
"chapter_url": ch.url,
|
||||||
"page_idx": page_idx,
|
"page_idx": page_idx,
|
||||||
"pages_done": pages_done_count[0],
|
"pages_done": pages_done,
|
||||||
"pages_total": pages_total,
|
"pages_total": pages_total,
|
||||||
})
|
})
|
||||||
|
|
||||||
image_paths = await get_chapter_images_and_download(
|
image_paths = await source.get_chapter_images_and_download(
|
||||||
ch_page, ch.url,
|
ch_page, ch.url,
|
||||||
dest_dir=tmp_path,
|
dest_dir=tmp_path,
|
||||||
manga_url=url,
|
manga_url=url,
|
||||||
@@ -211,7 +217,7 @@ async def download_manga(
|
|||||||
"chapter_url": ch.url})
|
"chapter_url": ch.url})
|
||||||
return
|
return
|
||||||
|
|
||||||
ch_name = _safe_chapter_name(ch)
|
ch_name = safe_chapter_name(ch)
|
||||||
ch_meta = MangaMeta(
|
ch_meta = MangaMeta(
|
||||||
series=manga.title_ru or manga.title,
|
series=manga.title_ru or manga.title,
|
||||||
series_full=manga.title_full or "",
|
series_full=manga.title_full or "",
|
||||||
@@ -298,6 +304,8 @@ async def download_manga(
|
|||||||
await ctx.close()
|
await ctx.close()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
# Прокидываем выше — обработка статуса уже сделана API-эндпоинтом
|
||||||
|
# (или воркером в _queue_worker_loop).
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Manga worker error {}: {}", url, e)
|
logger.error("Manga worker error {}: {}", url, e)
|
||||||
@@ -329,9 +337,19 @@ async def check_for_updates(
|
|||||||
db.add_history(manga_url=url, event_type="check_started")
|
db.add_history(manga_url=url, event_type="check_started")
|
||||||
await emit({"type": "check_started", "url": url})
|
await emit({"type": "check_started", "url": url})
|
||||||
|
|
||||||
|
# Резолвим источник
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
manga_row = db.get_manga(url)
|
||||||
|
if manga_row and manga_row.get("source_id"):
|
||||||
|
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||||
|
if source is None:
|
||||||
|
await emit({"type": "source_unknown", "url": url})
|
||||||
|
return []
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
_, page = await bm.new_page()
|
_, page = await bm.new_page()
|
||||||
manga = await get_manga_info(page, url)
|
manga = await source.get_manga_info(page, url)
|
||||||
await page.close()
|
await page.close()
|
||||||
if not manga:
|
if not manga:
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user