Compare commits
18 Commits
0aa057c991
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
| 672e199d3a | |||
| 0f8707fe93 | |||
| 84b24b2b5b | |||
| bb6f2d67d8 | |||
| 2cb244d973 | |||
| 07bc7ef1e0 | |||
| a7eaa22646 | |||
| 419614d295 | |||
| fcd1dfb74c | |||
| bc7b5bfe37 | |||
| 43597be020 | |||
| 469fd1ba94 | |||
| 9d5d840898 | |||
| b4e4a51ae5 | |||
| 87b692ba49 | |||
| 88bf301b60 | |||
| 77592c9a55 | |||
| 7c5ce807b8 |
320
ARCHITECTURE.md
320
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-режиме)
|
||||||
@@ -84,6 +86,7 @@ manga/
|
|||||||
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
||||||
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
|
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
|
||||||
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
|
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
|
||||||
|
| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика |
|
||||||
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
|
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
|
||||||
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
|
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
|
||||||
|
|
||||||
@@ -99,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 │
|
||||||
@@ -128,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.
|
||||||
@@ -247,8 +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`) |
|
||||||
|
| `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'`.
|
||||||
|
|
||||||
@@ -273,13 +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)` — обновляет пользовательские метаданные (название), не меняет папку.
|
||||||
|
- `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` — удаляет истёкшие сессии.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -297,13 +369,16 @@ class MangaInfo:
|
|||||||
3. get_manga_info() → MangaInfo
|
3. get_manga_info() → MangaInfo
|
||||||
4. update_manga_info() в БД
|
4. update_manga_info() в БД
|
||||||
5. upsert_chapter() для каждой главы
|
5. upsert_chapter() для каждой главы
|
||||||
6. Делим главы:
|
6. Определение папки:
|
||||||
|
├── если db.get_manga(url)["folder_name"] задан → использует его
|
||||||
|
└── иначе → _safe_name(title_ru or title)
|
||||||
|
7. Делим главы:
|
||||||
├── to_skip (status == "done" и resume=True)
|
├── to_skip (status == "done" и resume=True)
|
||||||
└── to_download (всё остальное)
|
└── to_download (всё остальное)
|
||||||
7. Отправляем chapter_skipped события для to_skip
|
8. Отправляем chapter_skipped события для to_skip
|
||||||
8. asyncio.Semaphore(chapter_concurrency)
|
9. asyncio.Semaphore(chapter_concurrency)
|
||||||
9. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||||
10. sync_chapters_done() → update_manga_status → "done"
|
11. sync_chapters_done() → update_manga_status → "done"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `process_chapter(ch)` (внутренняя корутина)
|
#### `process_chapter(ch)` (внутренняя корутина)
|
||||||
@@ -334,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`) требуют валидной сессии.
|
||||||
|
|
||||||
#### Глобальное состояние
|
#### Глобальное состояние
|
||||||
|
|
||||||
@@ -344,11 +429,20 @@ active_tasks: dict[str, asyncio.Task] # url → текущая Task загру
|
|||||||
ws_manager: ConnectionManager # set активных WebSocket-соединений
|
ws_manager: ConnectionManager # set активных WebSocket-соединений
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Вспомогательные функции
|
||||||
|
|
||||||
|
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
|
||||||
|
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
|
||||||
|
- `_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()`
|
||||||
|
|
||||||
@@ -356,20 +450,19 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
|
|||||||
|
|
||||||
#### `update_scheduler()`
|
#### `update_scheduler()`
|
||||||
|
|
||||||
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч):
|
Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис):
|
||||||
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`.
|
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
|
||||||
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`.
|
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
|
||||||
|
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
|
||||||
|
|
||||||
#### `_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
|
||||||
@@ -414,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` | Список всех манг с реальными счётчиками |
|
||||||
@@ -421,11 +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=` | Немедленно проверить новые главы |
|
||||||
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
|
| `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/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
|
||||||
|
| `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
|
||||||
|
| `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` |
|
||||||
@@ -448,6 +581,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
| `manga_stopped` | `{url}` | Остановлена |
|
| `manga_stopped` | `{url}` | Остановлена |
|
||||||
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
||||||
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
||||||
|
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
|
||||||
|
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
|
||||||
|
| `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}` | Глава пропущена (уже скачана) |
|
||||||
@@ -456,6 +592,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
||||||
| `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}` | Метаданные файлов обновлены |
|
||||||
|
| `source_domain_added` | `{source_id, domain}` | Добавлен домен к источнику |
|
||||||
|
| `source_domain_removed` | `{source_id, domain}` | Домен удалён у источника |
|
||||||
|
|
||||||
### Клиент → Сервер
|
### Клиент → Сервер
|
||||||
|
|
||||||
@@ -467,16 +606,24 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
|||||||
|
|
||||||
## 9. Фронтенд
|
## 9. Фронтенд
|
||||||
|
|
||||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк).
|
**Файл:** `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: [], // список источников
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -485,101 +632,64 @@ 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`) поле пароля скрыто.
|
||||||
|
- Удаление: кнопка ✕ (недоступна для системного администратора и для самого себя).
|
||||||
|
- Системный администратор помечен иконкой 🔒 с тултипом.
|
||||||
|
|
||||||
### Модальное окно детали
|
### Модальное окно детали
|
||||||
|
|
||||||
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок.
|
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок. Содержит кнопки:
|
||||||
|
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
|
||||||
|
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
|
||||||
|
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
|
||||||
|
- **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
|
||||||
|
|
||||||
---
|
### Карточки манги (кнопки)
|
||||||
|
|
||||||
## 10. Жизненный цикл загрузки манги
|
| Кнопка | Условие отображения | Действие |
|
||||||
|
|--------|---------------------|---------|
|
||||||
|
| ℹ️ | всегда | Открыть детальное модальное окно |
|
||||||
|
| ⚠️ N | `errors_count > 0` | Открыть вкладку ошибок в модалке |
|
||||||
|
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
|
||||||
|
| ▶ | `status` = `stopped` или `failed` | Возобновить |
|
||||||
|
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
|
||||||
|
| ✕ `[admin]` | всегда | Удалить |
|
||||||
|
|
||||||
```
|
### Позиции в очереди
|
||||||
POST /api/queue {urls: ["https://..."]}
|
|
||||||
│
|
|
||||||
├── db.add_manga(url) → status="queued"
|
|
||||||
├── download_queue.put({url, fmt})
|
|
||||||
├── ws_manager.broadcast(manga_queued)
|
|
||||||
└── asyncio.create_task(_fetch_preview(url)) ← быстрый предпросмотр
|
|
||||||
│
|
|
||||||
└── get_manga_info() → ws manga_preview (название, кол-во глав)
|
|
||||||
|
|
||||||
queue_worker() (фоновая Task)
|
Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди.
|
||||||
│
|
|
||||||
└── job = await download_queue.get()
|
|
||||||
│
|
|
||||||
└── asyncio.create_task(download_manga(url, fmt, ...))
|
|
||||||
│
|
|
||||||
├── status → "downloading"
|
|
||||||
├── get_manga_info() → ws manga_info
|
|
||||||
├── upsert_chapter() × N
|
|
||||||
│
|
|
||||||
├── to_skip → ws chapter_skipped × M
|
|
||||||
│
|
|
||||||
└── asyncio.gather(
|
|
||||||
process_chapter(ch1), ─┐
|
|
||||||
process_chapter(ch2), ├── параллельно
|
|
||||||
... ─┘
|
|
||||||
limit: Semaphore(CHAPTER_CONCURRENCY)
|
|
||||||
)
|
|
||||||
│
|
|
||||||
├── ctx.new_page()
|
|
||||||
├── get_chapter_images_and_download()
|
|
||||||
│ ├── page.route() перехват img
|
|
||||||
│ ├── ArrowRight листание
|
|
||||||
│ └── сохранение байт
|
|
||||||
├── export() → .cbz/.pdf/.epub
|
|
||||||
├── db.mark_done()
|
|
||||||
└── ws chapter_done / chapter_failed
|
|
||||||
|
|
||||||
└── status → "done" / "failed"
|
|
||||||
└── ws manga_done / manga_failed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Параллельная загрузка
|
|
||||||
|
|
||||||
### Параллельность глав
|
|
||||||
|
|
||||||
`CHAPTER_CONCURRENCY` (env, default `3`) — сколько глав загружается одновременно.
|
|
||||||
|
|
||||||
```
|
|
||||||
asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
|
||||||
|
|
||||||
Все N глав запускаются сразу через asyncio.gather(),
|
|
||||||
но одновременно в браузере открыто не более CHAPTER_CONCURRENCY вкладок.
|
|
||||||
```
|
|
||||||
|
|
||||||
Все вкладки работают в **одном** `BrowserContext` — это важно: cookies DDoS-Guard получены при открытии страницы манги и автоматически применяются ко всем вкладкам контекста.
|
|
||||||
|
|
||||||
### Защита от race condition
|
|
||||||
|
|
||||||
1. **Повторная проверка статуса** внутри семафора: если пока ждали семафор другая горутина уже скачала эту главу — пропустить.
|
|
||||||
2. **`db_lock`** — все SQLite-операции сериализованы через `asyncio.Lock()`. `sqlite3` не поддерживает concurrent writes.
|
|
||||||
3. **`counter_lock`** — атомарный инкремент счётчика `chapters_done` для правильных данных в WS-событиях.
|
|
||||||
|
|
||||||
### Параллельность манг
|
|
||||||
|
|
||||||
Манги в очереди обрабатываются **последовательно** (один воркер). Параллельная загрузка нескольких манг одновременно не реализована, чтобы не перегружать сайт и не создавать проблем с памятью Chromium.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -589,8 +699,11 @@ asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
|||||||
|
|
||||||
| Переменная | Default | Описание |
|
| Переменная | Default | Описание |
|
||||||
|------------|---------|---------|
|
|------------|---------|---------|
|
||||||
|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
|
||||||
|
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
|
||||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
|
||||||
|
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
|
||||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||||
|
|
||||||
### Пути (hardcoded в коде)
|
### Пути (hardcoded в коде)
|
||||||
@@ -630,7 +743,9 @@ ports:
|
|||||||
|
|
||||||
shm_size: "2gb" # Chromium требует shared memory
|
shm_size: "2gb" # Chromium требует shared memory
|
||||||
environment:
|
environment:
|
||||||
- UPDATE_INTERVAL_HOURS=6
|
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
|
||||||
|
- AUTH_LOGIN=... # системный администратор
|
||||||
|
- AUTH_PASSWORD=...
|
||||||
|
|
||||||
restart: unless-stopped # Автоперезапуск при падении
|
restart: unless-stopped # Автоперезапуск при падении
|
||||||
```
|
```
|
||||||
@@ -651,8 +766,7 @@ docker compose run --rm --entrypoint "" manga \
|
|||||||
|
|
||||||
После остановки контейнера все данные сохраняются на хосте:
|
После остановки контейнера все данные сохраняются на хосте:
|
||||||
- `./output/` — скачанные файлы.
|
- `./output/` — скачанные файлы.
|
||||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
|
- `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
|
||||||
- `./state/manga.log` — логи.
|
- `./state/manga.log` — логи.
|
||||||
|
|
||||||
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
||||||
|
|
||||||
|
|||||||
352
PLAN_MULTI_SOURCE.md
Normal file
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 + предупреждение + ручной выбор для неизвестных доменов
|
||||||
|
- Диалог смены источника с предупреждением о перепривязке домена
|
||||||
|
- Вкладка «Настройки → Источники»
|
||||||
|
|
||||||
|
|
||||||
81
README.md
81
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,12 +103,62 @@ output/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Авторизация и пользователи
|
||||||
|
|
||||||
|
Приложение использует многопользовательскую систему с ролями. Доступ к веб-интерфейсу защищён формой входа.
|
||||||
|
|
||||||
|
### Системный администратор (bootstrap)
|
||||||
|
|
||||||
|
При первом запуске приложение создаёт администратора из переменных окружения:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- AUTH_LOGIN=ваш_логин
|
||||||
|
- AUTH_PASSWORD=ваш_пароль
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот пользователь является **системным администратором** (`is_env_admin`):
|
||||||
|
- Помечен иконкой 🔒 в списке пользователей
|
||||||
|
- **Пароль нельзя изменить через интерфейс** — только через `AUTH_PASSWORD` в `docker-compose.yml`
|
||||||
|
- Нельзя удалить
|
||||||
|
|
||||||
|
Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
|
||||||
|
|
||||||
|
### Управление пользователями
|
||||||
|
|
||||||
|
Администратор может управлять пользователями через вкладку **⚙️ Настройки** → раздел **Пользователи**:
|
||||||
|
|
||||||
|
- **Создать** пользователя с указанием логина, пароля и роли
|
||||||
|
- **Изменить** роль или пароль существующего пользователя
|
||||||
|
- **Удалить** пользователя (кроме системного администратора и самого себя)
|
||||||
|
|
||||||
|
### Роли
|
||||||
|
|
||||||
|
| Роль | Описание |
|
||||||
|
|------|---------|
|
||||||
|
| `admin` | Полный доступ: управление пользователями, удаление и принудительная перезагрузка манг, управление источниками, приоритизация очереди |
|
||||||
|
| `user` | Может добавлять мангу, управлять только своими загрузками |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Конфигурация (docker-compose.yml)
|
## Конфигурация (docker-compose.yml)
|
||||||
|
|
||||||
| Переменная | Default | Описание |
|
| Переменная | Default | Описание |
|
||||||
|------------|---------|---------|
|
|------------|---------|---------|
|
||||||
|
| `AUTH_LOGIN` | — | Логин системного администратора (создаётся при первом старте) |
|
||||||
|
| `AUTH_PASSWORD` | — | Пароль системного администратора |
|
||||||
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
|
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
|
||||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено |
|
||||||
|
| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) |
|
||||||
|
|
||||||
|
### Примеры расписания (`UPDATE_SCHEDULE`)
|
||||||
|
|
||||||
|
```
|
||||||
|
0 */6 * * * — каждые 6 часов
|
||||||
|
0 3 * * * — каждый день в 03:00 UTC
|
||||||
|
0 3 * * MON — каждый понедельник в 03:00
|
||||||
|
*/30 * * * * — каждые 30 минут
|
||||||
|
— (пусто) — планировщик отключён
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,4 +180,31 @@ output/
|
|||||||
|
|
||||||
Для завершённых серий (`pub_status = completed`) в `ComicInfo.xml` записывается поле `<Count>` — Komga отображает прогресс чтения серии.
|
Для завершённых серий (`pub_status = completed`) в `ComicInfo.xml` записывается поле `<Count>` — Komga отображает прогресс чтения серии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Редактирование метаданных
|
||||||
|
|
||||||
|
Через веб-интерфейс можно изменить название серии, не перекачивая файлы:
|
||||||
|
|
||||||
|
1. Кликните на строку манги → откроется окно деталей.
|
||||||
|
2. Нажмите **✏️ Редактировать название**.
|
||||||
|
3. Измените «Название (ru)» и/или «Полное название».
|
||||||
|
4. Нажмите **Сохранить** — метаданные обновятся автоматически во всех скачанных файлах.
|
||||||
|
|
||||||
|
> **Важно:** папка на диске при этом **не переименовывается**. Чтобы переименовать папку — используйте отдельную функцию ниже.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переименование папки
|
||||||
|
|
||||||
|
Через веб-интерфейс можно изменить имя папки, в которую сохраняются файлы манги:
|
||||||
|
|
||||||
|
1. Кликните на строку манги → откроется окно деталей.
|
||||||
|
2. Нажмите **📁 Переименовать папку**.
|
||||||
|
3. Введите новое имя (спецсимволы удалятся автоматически, пробелы заменятся на `_`).
|
||||||
|
4. Нажмите **Переименовать**.
|
||||||
|
|
||||||
|
После переименования:
|
||||||
|
- Физическая папка на диске будет переименована.
|
||||||
|
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||||
|
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ services:
|
|||||||
- ./state:/app/state
|
- ./state:/app/state
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- UPDATE_INTERVAL_HOURS=6
|
# Расписание авто-проверки новых глав (cron-синтаксис).
|
||||||
|
# Примеры: "0 */6 * * *" — каждые 6 ч | "0 3 * * *" — каждый день в 03:00
|
||||||
|
# Оставьте пустым чтобы отключить планировщик.
|
||||||
|
# Устаревший формат UPDATE_INTERVAL_HOURS=6 тоже поддерживается.
|
||||||
|
- UPDATE_SCHEDULE=0 */6 * * *
|
||||||
|
# Авторизация (оба параметра должны быть заданы чтобы включить защиту)
|
||||||
|
- AUTH_LOGIN=StenFredd
|
||||||
|
- AUTH_PASSWORD=111111
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
shm_size: "2gb"
|
shm_size: "2gb"
|
||||||
@@ -16,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
|
||||||
|
|||||||
1394
frontend/index.html
1394
frontend/index.html
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 |
@@ -9,3 +9,4 @@ fastapi==0.111.0
|
|||||||
uvicorn[standard]==0.29.0
|
uvicorn[standard]==0.29.0
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
pypdf==4.2.0
|
pypdf==4.2.0
|
||||||
|
croniter==3.0.3
|
||||||
|
|||||||
1002
src/api.py
1002
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class MangaMeta:
|
|||||||
language: str = "ru"
|
language: str = "ru"
|
||||||
summary: str = "" # Описание/синопсис серии
|
summary: str = "" # Описание/синопсис серии
|
||||||
genre: str = "" # Жанры через запятую (для ComicInfo Genre)
|
genre: str = "" # Жанры через запятую (для ComicInfo Genre)
|
||||||
|
tags: str = "" # Теги через запятую (для ComicInfo Tags)
|
||||||
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
|
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ def _make_comic_info(meta: MangaMeta) -> str:
|
|||||||
add("Count", meta.chapters_total)
|
add("Count", meta.chapters_total)
|
||||||
|
|
||||||
add("Genre", meta.genre)
|
add("Genre", meta.genre)
|
||||||
|
add("Tags", meta.tags)
|
||||||
add("LanguageISO", meta.language)
|
add("LanguageISO", meta.language)
|
||||||
|
|
||||||
# Manga = YesAndRightToLeft — стандартная японская манга
|
# Manga = YesAndRightToLeft — стандартная японская манга
|
||||||
@@ -131,8 +133,12 @@ def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
|
|||||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||||
|
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())]
|
|
||||||
|
|
||||||
|
|||||||
76
src/sources/__init__.py
Normal file
76
src/sources/__init__.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Реестр источников манги.
|
||||||
|
|
||||||
|
Для добавления нового источника:
|
||||||
|
1. Создать файл src/sources/mysource.py с классом, реализующим MangaSourceProtocol
|
||||||
|
2. Импортировать его здесь и добавить в список SOURCES
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import MangaSourceProtocol
|
||||||
|
from .readmanga import ReadmangaSource
|
||||||
|
from .mangalib import MangalibSource
|
||||||
|
|
||||||
|
# ── Регистрация источников ─────────────────────
|
||||||
|
# Добавьте новые источники сюда:
|
||||||
|
SOURCES: list = [
|
||||||
|
ReadmangaSource(),
|
||||||
|
MangalibSource(),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Быстрый поиск по slug
|
||||||
|
_BY_SLUG: dict[str, object] = {s.slug: s for s in SOURCES}
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRegistry:
|
||||||
|
"""Реестр источников. Источники определяются только в коде."""
|
||||||
|
|
||||||
|
def get_by_slug(self, slug: str) -> Optional[object]:
|
||||||
|
return _BY_SLUG.get(slug)
|
||||||
|
|
||||||
|
def get_by_db_id(self, source_id: int, db) -> Optional[object]:
|
||||||
|
"""Резолвит адаптер через БД: source_id → slug → экземпляр."""
|
||||||
|
row = db.get_source_by_id(source_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _BY_SLUG.get(row["slug"])
|
||||||
|
|
||||||
|
def all_sources(self) -> list:
|
||||||
|
return list(SOURCES)
|
||||||
|
|
||||||
|
def all_slugs(self) -> list[str]:
|
||||||
|
return [s.slug for s in SOURCES]
|
||||||
|
|
||||||
|
|
||||||
|
registry = SourceRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_for_url(url: str, db) -> Optional[object]:
|
||||||
|
"""
|
||||||
|
Определяет источник по домену URL.
|
||||||
|
Ищет домен в таблице source_domains → возвращает адаптер.
|
||||||
|
Если домен не зарегистрирован — возвращает None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
row = db.get_source_by_domain(domain)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _BY_SLUG.get(row["slug"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_domain(url: str) -> str:
|
||||||
|
"""Извлекает домен без www."""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
67
src/sources/base.py
Normal file
67
src/sources/base.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Базовые модели данных и Protocol-интерфейс для источников манги.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRequiredError(Exception):
|
||||||
|
"""Источник требует авторизации — токен не задан или просрочен."""
|
||||||
|
def __init__(self, source_slug: str):
|
||||||
|
self.source_slug = source_slug
|
||||||
|
super().__init__(f"Auth required for source: {source_slug}")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Модели данных (общие для всех источников)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chapter:
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
number: float = 0.0
|
||||||
|
volume: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MangaInfo:
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
chapters: list[Chapter] = field(default_factory=list)
|
||||||
|
pub_status: str = "unknown" # completed / ongoing / unknown
|
||||||
|
title_ru: str = ""
|
||||||
|
title_full: str = ""
|
||||||
|
description: str = ""
|
||||||
|
genres: list[str] = field(default_factory=list)
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
cover_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Интерфейс источника
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MangaSourceProtocol(Protocol):
|
||||||
|
slug: str # уникальный код источника в коде ("readmanga")
|
||||||
|
display_name: str # название для UI ("ReadManga")
|
||||||
|
|
||||||
|
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
|
||||||
|
"""Возвращает информацию о манге и список глав."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_chapter_images_and_download(
|
||||||
|
self,
|
||||||
|
page: Page,
|
||||||
|
chapter_url: str,
|
||||||
|
dest_dir: Path,
|
||||||
|
manga_url: Optional[str] = None,
|
||||||
|
on_page: object = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Скачивает страницы главы в dest_dir и возвращает список путей."""
|
||||||
|
...
|
||||||
|
|
||||||
845
src/sources/mangalib.py
Normal file
845
src/sources/mangalib.py
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
"""
|
||||||
|
Адаптер MangaLib: поддерживает mangalib.me и его зеркала.
|
||||||
|
|
||||||
|
Принцип работы:
|
||||||
|
- Список глав: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapters
|
||||||
|
Возвращает все главы сразу (не требует пагинации).
|
||||||
|
URL главы: {origin}/ru/{manga_slug}/read/v{vol}/c{num}
|
||||||
|
- Страница главы: перехватываем ответ api.cdnlibs.org/api/manga/{slug}/chapter?...
|
||||||
|
Получаем pages[] с полями: image (filename), url (relative path), slug (page index 1-based).
|
||||||
|
Изображения: {server}{page.url}, CDN = mixlib.me / imglib.info.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from .base import Chapter, MangaInfo, AuthRequiredError
|
||||||
|
|
||||||
|
|
||||||
|
class MangalibSource:
|
||||||
|
slug = "mangalib"
|
||||||
|
display_name = "MangaLib"
|
||||||
|
supports_auth_token = True
|
||||||
|
|
||||||
|
# CDN-домены для изображений глав (актуальные)
|
||||||
|
cdn_patterns = ["mixlib.me", "imglib.info", "imglib", "imgslib"]
|
||||||
|
|
||||||
|
# Токен авторизации — устанавливается воркером из настроек источника в БД
|
||||||
|
auth_token: Optional[str] = None
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Страница манги — список глав
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
|
||||||
|
"""Открывает страницу манги и возвращает список всех глав."""
|
||||||
|
logger.info("Загружаем страницу манги MangaLib: {}", url)
|
||||||
|
|
||||||
|
chapters_url = _ensure_chapters_section(url)
|
||||||
|
base_manga_url = url.split("?")[0].rstrip("/")
|
||||||
|
|
||||||
|
# Слушаем API-ответы до навигации
|
||||||
|
chapters_api_data: list = []
|
||||||
|
manga_api_data: dict = {}
|
||||||
|
chapters_auth_error: list = []
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def on_response(resp):
|
||||||
|
resp_url = resp.url
|
||||||
|
if "api.cdnlibs.org" not in resp_url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# api.cdnlibs.org/api/manga/{slug}/chapters (без query-параметров)
|
||||||
|
if re.search(r"/chapters$", resp_url):
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
chapters_auth_error.append(True)
|
||||||
|
return
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body)
|
||||||
|
raw = data.get("data", [])
|
||||||
|
if isinstance(raw, list) and raw:
|
||||||
|
async with lock:
|
||||||
|
if not chapters_api_data:
|
||||||
|
chapters_api_data.extend(raw)
|
||||||
|
logger.debug("Chapters API: {} глав получено", len(raw))
|
||||||
|
# api.cdnlibs.org/api/manga/{slug}?fields[]=...
|
||||||
|
elif re.search(r"/manga/[^/]+$", resp_url.split("?")[0]) and "fields" in resp_url:
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body)
|
||||||
|
raw = data.get("data", {})
|
||||||
|
if isinstance(raw, dict) and raw:
|
||||||
|
async with lock:
|
||||||
|
if not manga_api_data:
|
||||||
|
manga_api_data.update(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("API parse error: {}", e)
|
||||||
|
|
||||||
|
if self.auth_token:
|
||||||
|
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
ok = await _navigate(page, chapters_url)
|
||||||
|
if not ok:
|
||||||
|
mirror_chapters_url = _switch_to_mirror(chapters_url)
|
||||||
|
if mirror_chapters_url != chapters_url:
|
||||||
|
logger.info("Основной домен недоступен, пробуем зеркало: {}", mirror_chapters_url)
|
||||||
|
ok = await _navigate(page, mirror_chapters_url)
|
||||||
|
if ok:
|
||||||
|
chapters_url = mirror_chapters_url
|
||||||
|
base_manga_url = _switch_to_mirror(base_manga_url)
|
||||||
|
if not ok:
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ждём API-ответов (обычно приходят за 1-3 секунды)
|
||||||
|
for _ in range(30):
|
||||||
|
async with lock:
|
||||||
|
if chapters_api_data:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if chapters_auth_error and not chapters_api_data:
|
||||||
|
raise AuthRequiredError(self.slug)
|
||||||
|
|
||||||
|
# Извлекаем pub_status из API манги (надёжнее DOM)
|
||||||
|
async with lock:
|
||||||
|
manga_meta = dict(manga_api_data)
|
||||||
|
pub_status = _pub_status_from_api(manga_meta)
|
||||||
|
if pub_status == "unknown":
|
||||||
|
pub_status = await _extract_pub_status(page)
|
||||||
|
|
||||||
|
# Предпочитаем имена из API (надёжнее DOM и page.title)
|
||||||
|
async with lock:
|
||||||
|
manga_meta_snap = dict(manga_api_data)
|
||||||
|
title_ru = (manga_meta_snap.get("rus_name") or "").strip()
|
||||||
|
title_name = (manga_meta_snap.get("name") or "").strip()
|
||||||
|
if not title_ru:
|
||||||
|
title_ru = await _extract_title(page)
|
||||||
|
title_full = (f"{title_ru} / {title_name}" if title_name and title_ru and title_name != title_ru
|
||||||
|
else title_ru or title_name)
|
||||||
|
if not title_full:
|
||||||
|
try:
|
||||||
|
page_title = await page.title()
|
||||||
|
page_title = re.sub(r"\s*([-–|•]|читать|онлайн).*$", "", page_title, flags=re.IGNORECASE).strip()
|
||||||
|
title_full = page_title
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not title_ru:
|
||||||
|
title_ru = title_full
|
||||||
|
|
||||||
|
logger.info("Манга: {} | ru: {}", title_full, title_ru)
|
||||||
|
logger.info("Статус выпуска: {}", pub_status)
|
||||||
|
|
||||||
|
description = await _extract_description(page)
|
||||||
|
genres = await _extract_genres(page)
|
||||||
|
|
||||||
|
# Получаем обложку, описание и теги из API
|
||||||
|
async with lock:
|
||||||
|
manga_meta_for_extras = dict(manga_api_data)
|
||||||
|
|
||||||
|
cover_url, extra_description, tags = await _fetch_extra_meta(
|
||||||
|
page, manga_meta_for_extras, url, self.auth_token
|
||||||
|
)
|
||||||
|
if extra_description:
|
||||||
|
description = extra_description
|
||||||
|
if not description:
|
||||||
|
description = await _extract_description(page)
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
raw_chapters = list(chapters_api_data)
|
||||||
|
|
||||||
|
if raw_chapters:
|
||||||
|
chapters = _chapters_from_api(raw_chapters, base_manga_url)
|
||||||
|
else:
|
||||||
|
logger.warning("Chapters API не ответил, используем DOM-fallback")
|
||||||
|
chapters = await _chapters_from_dom(page, base_manga_url)
|
||||||
|
|
||||||
|
logger.info("Найдено глав: {}", len(chapters))
|
||||||
|
|
||||||
|
return MangaInfo(
|
||||||
|
title=title_ru or title_full,
|
||||||
|
url=url,
|
||||||
|
chapters=chapters,
|
||||||
|
pub_status=pub_status,
|
||||||
|
title_ru=title_ru,
|
||||||
|
title_full=title_full,
|
||||||
|
description=description,
|
||||||
|
genres=genres,
|
||||||
|
tags=tags,
|
||||||
|
cover_url=cover_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Скачивание главы
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_chapter_images_and_download(
|
||||||
|
self,
|
||||||
|
page: Page,
|
||||||
|
chapter_url: str,
|
||||||
|
dest_dir: Path,
|
||||||
|
manga_url: Optional[str] = None,
|
||||||
|
on_page: object = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""
|
||||||
|
1. Открывает страницу читалки.
|
||||||
|
2. Пассивно наблюдает ответы через page.on("response"):
|
||||||
|
- api.cdnlibs.org/chapter? → список страниц
|
||||||
|
- api.cdnlibs.org/imageServers → серверы CDN
|
||||||
|
3. Скачивает все страницы через page.context.request.get()
|
||||||
|
(разделяет cookies с браузером, без CORS-ограничений).
|
||||||
|
"""
|
||||||
|
t_start = time.monotonic()
|
||||||
|
ch_id = chapter_url.rstrip("/").split("/")[-1]
|
||||||
|
logger.info("[{}] Загружаем главу MangaLib: {}", ch_id, chapter_url)
|
||||||
|
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
referer_origin = _base_url(manga_url or chapter_url)
|
||||||
|
|
||||||
|
chapter_api: dict = {}
|
||||||
|
image_servers: list = []
|
||||||
|
chapter_auth_error: list = []
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def on_response(resp):
|
||||||
|
resp_url = resp.url
|
||||||
|
try:
|
||||||
|
if "api.cdnlibs.org" in resp_url and "/chapter?" in resp_url:
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
chapter_auth_error.append(True)
|
||||||
|
return
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body)
|
||||||
|
async with lock:
|
||||||
|
if not chapter_api.get("pages"):
|
||||||
|
chapter_api.update(data.get("data", {}))
|
||||||
|
elif "api.cdnlibs.org" in resp_url and "imageServers" in resp_url:
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body)
|
||||||
|
servers = data.get("data", {}).get("imageServers", [])
|
||||||
|
async with lock:
|
||||||
|
if not image_servers:
|
||||||
|
image_servers.extend(s["url"] for s in servers if "url" in s)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.auth_token:
|
||||||
|
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
referer = manga_url or referer_origin
|
||||||
|
ok = await _navigate(page, chapter_url, referer=referer)
|
||||||
|
if not ok:
|
||||||
|
mirror_chapter_url = _switch_to_mirror(chapter_url)
|
||||||
|
if mirror_chapter_url != chapter_url:
|
||||||
|
logger.info("[{}] Основной домен недоступен, пробуем зеркало: {}", ch_id, mirror_chapter_url)
|
||||||
|
mirror_referer = _switch_to_mirror(referer) if referer else referer
|
||||||
|
ok = await _navigate(page, mirror_chapter_url, referer=mirror_referer)
|
||||||
|
if ok:
|
||||||
|
chapter_url = mirror_chapter_url
|
||||||
|
referer_origin = _base_url(mirror_chapter_url)
|
||||||
|
if not ok:
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Ждём ответ chapter API (обычно приходит за 1-3 секунды)
|
||||||
|
for _ in range(40):
|
||||||
|
async with lock:
|
||||||
|
if chapter_api.get("pages"):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if chapter_auth_error and not chapter_api.get("pages"):
|
||||||
|
raise AuthRequiredError(self.slug)
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
pages_info = list(chapter_api.get("pages", []))
|
||||||
|
servers_list = list(image_servers)
|
||||||
|
|
||||||
|
if not pages_info:
|
||||||
|
try:
|
||||||
|
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
||||||
|
except Exception:
|
||||||
|
page_info = "?"
|
||||||
|
logger.error("[{}] API не вернул данные страниц. Страница: {}", ch_id, page_info)
|
||||||
|
return []
|
||||||
|
|
||||||
|
total = len(pages_info)
|
||||||
|
logger.info("[{}] Страниц по API: {}", ch_id, total)
|
||||||
|
|
||||||
|
# Строим маппинг: filename → 0-based index (slug 1-based)
|
||||||
|
fname_to_idx: dict[str, int] = {}
|
||||||
|
page_url_by_idx: dict[int, str] = {}
|
||||||
|
for p in pages_info:
|
||||||
|
try:
|
||||||
|
idx = int(p.get("slug", 0)) - 1
|
||||||
|
if idx < 0:
|
||||||
|
continue
|
||||||
|
fname = p.get("image", "")
|
||||||
|
url_part = p.get("url", "")
|
||||||
|
if fname:
|
||||||
|
fname_to_idx[fname] = idx
|
||||||
|
if url_part:
|
||||||
|
page_url_by_idx[idx] = url_part
|
||||||
|
url_fname = url_part.rstrip("/").split("/")[-1]
|
||||||
|
if url_fname and url_fname not in fname_to_idx:
|
||||||
|
fname_to_idx[url_fname] = idx
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Определяем CDN сервер из img src или constants API
|
||||||
|
server = await _detect_server(page, servers_list)
|
||||||
|
logger.info("[{}] CDN сервер: {}", ch_id, server)
|
||||||
|
alt_servers = [s for s in servers_list if s != server]
|
||||||
|
|
||||||
|
# Скачиваем все страницы через Playwright APIRequestContext
|
||||||
|
captured: dict[str, bytes] = {}
|
||||||
|
failed_idxs: list[int] = []
|
||||||
|
all_servers = [server] + alt_servers
|
||||||
|
logger.info("[{}] Скачиваем {} страниц...", ch_id, total)
|
||||||
|
|
||||||
|
for idx in range(total):
|
||||||
|
url_part = page_url_by_idx.get(idx, "")
|
||||||
|
if not url_part:
|
||||||
|
continue
|
||||||
|
fname = url_part.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
body = None
|
||||||
|
for srv in all_servers:
|
||||||
|
body = await _api_fetch(page, srv + url_part, referer_origin)
|
||||||
|
if body:
|
||||||
|
if srv != server:
|
||||||
|
logger.debug("[{}] alt сервер OK: {} ({})", ch_id, fname, srv)
|
||||||
|
break
|
||||||
|
|
||||||
|
if body:
|
||||||
|
captured[fname] = body
|
||||||
|
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
|
||||||
|
if on_page:
|
||||||
|
try:
|
||||||
|
asyncio.ensure_future(on_page(0, 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
failed_idxs.append(idx)
|
||||||
|
|
||||||
|
# Retry провалившихся страниц с задержкой
|
||||||
|
if failed_idxs:
|
||||||
|
logger.info("[{}] Retry {} страниц с задержкой...", ch_id, len(failed_idxs))
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
for idx in failed_idxs:
|
||||||
|
url_part = page_url_by_idx.get(idx, "")
|
||||||
|
if not url_part:
|
||||||
|
continue
|
||||||
|
fname = url_part.rstrip("/").split("/")[-1]
|
||||||
|
body = None
|
||||||
|
for srv in all_servers:
|
||||||
|
body = await _api_fetch(page, srv + url_part, referer_origin)
|
||||||
|
if body:
|
||||||
|
break
|
||||||
|
if body:
|
||||||
|
captured[fname] = body
|
||||||
|
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||||
|
if on_page:
|
||||||
|
try:
|
||||||
|
asyncio.ensure_future(on_page(0, 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Не удалось скачать: {}", ch_id, fname)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - t_start
|
||||||
|
matched = sum(1 for f in captured if f in fname_to_idx)
|
||||||
|
logger.info("[{}] Скачано: {}/{} за {:.1f}с", ch_id, matched, total, elapsed)
|
||||||
|
|
||||||
|
# Сохраняем файлы
|
||||||
|
paths: dict[int, Path] = {}
|
||||||
|
for fname, body in captured.items():
|
||||||
|
idx = fname_to_idx.get(fname)
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
ext = _get_ext(fname)
|
||||||
|
p = dest_dir / f"{idx:04d}{ext}"
|
||||||
|
p.write_bytes(body)
|
||||||
|
paths[idx] = p
|
||||||
|
|
||||||
|
missing_idxs = [i for i in range(total) if i not in paths]
|
||||||
|
if missing_idxs:
|
||||||
|
logger.warning("[{}] Пропущено {}/{} стр. №: {}",
|
||||||
|
ch_id, len(missing_idxs), total, [i + 1 for i in missing_idxs])
|
||||||
|
|
||||||
|
return [paths[i] for i in sorted(paths.keys())]
|
||||||
|
|
||||||
|
async def get_chapter_page_count(
|
||||||
|
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
|
||||||
|
) -> int:
|
||||||
|
"""Открывает главу и возвращает количество страниц через API без скачивания изображений."""
|
||||||
|
pages_info: list = []
|
||||||
|
auth_err: list = []
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def on_response(resp):
|
||||||
|
try:
|
||||||
|
if "api.cdnlibs.org" in resp.url and "/chapter?" in resp.url:
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
auth_err.append(True)
|
||||||
|
return
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body)
|
||||||
|
async with lock:
|
||||||
|
if not pages_info:
|
||||||
|
pages_info.extend(data.get("data", {}).get("pages", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.auth_token:
|
||||||
|
await page.set_extra_http_headers({"Authorization": f"Bearer {self.auth_token}"})
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
referer = manga_url or _base_url(chapter_url)
|
||||||
|
ok = await _navigate(page, chapter_url, referer=referer)
|
||||||
|
if not ok:
|
||||||
|
mirror_url = _switch_to_mirror(chapter_url)
|
||||||
|
if mirror_url != chapter_url:
|
||||||
|
ok = await _navigate(
|
||||||
|
page, mirror_url,
|
||||||
|
referer=_switch_to_mirror(referer) if referer else referer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for _ in range(40):
|
||||||
|
async with lock:
|
||||||
|
if pages_info or auth_err:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if auth_err and not pages_info:
|
||||||
|
raise AuthRequiredError(self.slug)
|
||||||
|
|
||||||
|
return len(pages_info)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Вспомогательные функции (приватные)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Зеркальные домены: при недоступности основного переключаемся на зеркало
|
||||||
|
_MIRROR_MAP = {
|
||||||
|
"mangalib.me": "mangalib.org",
|
||||||
|
"mangalib.org": "mangalib.me",
|
||||||
|
"hentailib.me": "mangalib.org",
|
||||||
|
"yaoilib.me": "mangalib.org",
|
||||||
|
"readlib.net": "mangalib.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _switch_to_mirror(url: str) -> str:
|
||||||
|
"""Заменяет домен в URL на зеркало из _MIRROR_MAP. Возвращает исходный URL если зеркала нет."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.netloc.lower().removeprefix("www.")
|
||||||
|
mirror = _MIRROR_MAP.get(host)
|
||||||
|
if not mirror:
|
||||||
|
return url
|
||||||
|
return parsed._replace(netloc=mirror).geturl()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_chapters_section(url: str) -> str:
|
||||||
|
if "section=chapters" in url:
|
||||||
|
return url
|
||||||
|
sep = "&" if "?" in url else "?"
|
||||||
|
return url + sep + "section=chapters"
|
||||||
|
|
||||||
|
|
||||||
|
def _manga_slug_from_url(url: str) -> str:
|
||||||
|
"""Извлекает slug манги из URL страницы или главы.
|
||||||
|
|
||||||
|
Примеры входных URL:
|
||||||
|
https://mangalib.me/ru/manga/11312--subete... → 11312--subete...
|
||||||
|
https://mangalib.me/ru/11312--subete.../read/v1/c1 → 11312--subete...
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
parts = [p for p in parsed.path.split("/") if p]
|
||||||
|
# Убираем языковой префикс ('ru', 'en', ...)
|
||||||
|
if parts and len(parts[0]) <= 3 and parts[0].isalpha():
|
||||||
|
parts = parts[1:]
|
||||||
|
# Убираем 'manga' если есть
|
||||||
|
if parts and parts[0] == "manga":
|
||||||
|
parts = parts[1:]
|
||||||
|
return parts[0] if parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _chapters_from_api(raw: list, manga_url: str) -> list[Chapter]:
|
||||||
|
"""Строит список глав из ответа api.cdnlibs.org/chapters."""
|
||||||
|
parsed = urlparse(manga_url)
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
slug = _manga_slug_from_url(manga_url)
|
||||||
|
|
||||||
|
# Определяем языковой префикс из оригинального URL (/ru/, /en/, ...)
|
||||||
|
path_parts = [p for p in parsed.path.split("/") if p]
|
||||||
|
lang_prefix = path_parts[0] if path_parts and len(path_parts[0]) <= 3 else "ru"
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
for ch in raw:
|
||||||
|
try:
|
||||||
|
vol = str(ch.get("volume") or "1")
|
||||||
|
num = str(ch.get("number") or "0")
|
||||||
|
name = ch.get("name") or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
number_f = float(num)
|
||||||
|
except Exception:
|
||||||
|
number_f = 0.0
|
||||||
|
try:
|
||||||
|
vol_i = int(float(vol))
|
||||||
|
except Exception:
|
||||||
|
vol_i = 0
|
||||||
|
|
||||||
|
ch_url = f"{origin}/{lang_prefix}/{slug}/read/v{vol}/c{num}"
|
||||||
|
|
||||||
|
title = f"Том {vol}, Глава {num}"
|
||||||
|
if name:
|
||||||
|
title += f" - {name}"
|
||||||
|
|
||||||
|
chapters.append(Chapter(title=title, url=ch_url, number=number_f, volume=vol_i))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Пропуск главы из API: {}", e)
|
||||||
|
|
||||||
|
chapters.sort(key=lambda c: (c.volume, c.number))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
async def _chapters_from_dom(page: Page, manga_url: str) -> list[Chapter]:
|
||||||
|
"""Fallback: извлекает главы из DOM-ссылок вида /read/v{vol}/c{num}."""
|
||||||
|
try:
|
||||||
|
raw = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="/read/v"]'));
|
||||||
|
const result = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const a of links) {
|
||||||
|
const href = a.href;
|
||||||
|
if (!href || seen.has(href)) continue;
|
||||||
|
if (!/\\/read\\/v\\d/.test(href)) continue;
|
||||||
|
const text = a.textContent.trim();
|
||||||
|
// Пропускаем ссылки без нормального текста (кнопки навигации и т.п.)
|
||||||
|
if (!text || /^\\d+\\s*\\/\\s*\\d+$/.test(text)) continue;
|
||||||
|
seen.add(href);
|
||||||
|
result.push({ href, text });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
for item in raw:
|
||||||
|
href = item["href"]
|
||||||
|
m = re.search(r"/read/v(\d+(?:\.\d+)?)/c(\d+(?:\.\d+)?)", href)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
vol_s, num_s = m.group(1), m.group(2)
|
||||||
|
try:
|
||||||
|
number_f = float(num_s)
|
||||||
|
vol_i = int(float(vol_s))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
text = item["text"] or f"Том {vol_s}, Глава {num_s}"
|
||||||
|
chapters.append(Chapter(title=text, url=href, number=number_f, volume=vol_i))
|
||||||
|
|
||||||
|
chapters.sort(key=lambda c: (c.volume, c.number))
|
||||||
|
return chapters
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("_chapters_from_dom: {}", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _pub_status_from_api(manga_meta: dict) -> str:
|
||||||
|
"""Извлекает статус публикации из ответа API манги."""
|
||||||
|
status = manga_meta.get("status", {})
|
||||||
|
if isinstance(status, dict):
|
||||||
|
label = (status.get("label") or "").lower()
|
||||||
|
if "завершён" in label or "завершен" in label or "complete" in label:
|
||||||
|
return "completed"
|
||||||
|
if "продолжает" in label or "ongoing" in label or "выпускает" in label:
|
||||||
|
return "ongoing"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _navigate(page: Page, url: str, retries: int = 3,
|
||||||
|
referer: str | None = None) -> bool:
|
||||||
|
if referer is None:
|
||||||
|
p = urlparse(url)
|
||||||
|
referer = f"{p.scheme}://{p.netloc}/"
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded",
|
||||||
|
timeout=60_000, referer=referer)
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_title(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
if (window.__DATA__ && window.__DATA__.manga) {
|
||||||
|
const m = window.__DATA__.manga;
|
||||||
|
return m.rus_name || m.name || '';
|
||||||
|
}
|
||||||
|
const selectors = [
|
||||||
|
'.media-name__main',
|
||||||
|
'.manga-name h1',
|
||||||
|
'h1.media-title',
|
||||||
|
'h1.page-title',
|
||||||
|
'h1',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_pub_status(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.status) {
|
||||||
|
const s = window.__DATA__.manga.status;
|
||||||
|
const label = (s.label || s.name || '').toLowerCase();
|
||||||
|
if (label.includes('завершён') || label.includes('завершен') || label.includes('complete')) return 'completed';
|
||||||
|
if (label.includes('продолжает') || label.includes('ongoing') || label.includes('выпускает')) return 'ongoing';
|
||||||
|
}
|
||||||
|
const selectors = [
|
||||||
|
'.media-info-item__status',
|
||||||
|
'.status-value',
|
||||||
|
'[class*="status"] .value',
|
||||||
|
'[class*="status"]',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (!el) continue;
|
||||||
|
const t = el.textContent.toLowerCase();
|
||||||
|
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
||||||
|
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or "unknown"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_description(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.summary) {
|
||||||
|
return window.__DATA__.manga.summary;
|
||||||
|
}
|
||||||
|
const selectors = [
|
||||||
|
'.media-description__text',
|
||||||
|
'.description-text',
|
||||||
|
'.manga-description',
|
||||||
|
'[class*="description"] p',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()[:2000]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_genres(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
if (window.__DATA__ && window.__DATA__.manga && window.__DATA__.manga.genres) {
|
||||||
|
return window.__DATA__.manga.genres.map(g => g.name || g.label || '').filter(Boolean);
|
||||||
|
}
|
||||||
|
const selectors = [
|
||||||
|
'.genre-list a',
|
||||||
|
'.media-tags a',
|
||||||
|
'.tags a',
|
||||||
|
'[class*="genre"] a',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const els = document.querySelectorAll(sel);
|
||||||
|
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_summary_doc(doc) -> str:
|
||||||
|
"""Конвертирует ProseMirror JSON-документ в plain text."""
|
||||||
|
if not doc or not isinstance(doc, dict):
|
||||||
|
return ""
|
||||||
|
if doc.get("type") == "text":
|
||||||
|
return doc.get("text", "")
|
||||||
|
parts = []
|
||||||
|
for node in doc.get("content", []):
|
||||||
|
text = _parse_summary_doc(node)
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_extra_meta(
|
||||||
|
page: Page,
|
||||||
|
manga_api_data: dict,
|
||||||
|
manga_url: str,
|
||||||
|
auth_token: str | None,
|
||||||
|
) -> tuple[str, str, list[str]]:
|
||||||
|
"""
|
||||||
|
Возвращает (cover_url, description, tags) из уже полученных данных API или,
|
||||||
|
если нужных полей нет, делает явный supplementary-запрос к API.
|
||||||
|
"""
|
||||||
|
def _extract_from_data(data: dict) -> tuple[str, str, list[str]]:
|
||||||
|
cover_url = ""
|
||||||
|
cover_obj = data.get("cover")
|
||||||
|
if isinstance(cover_obj, dict):
|
||||||
|
cover_url = cover_obj.get("default") or cover_obj.get("thumbnail") or ""
|
||||||
|
|
||||||
|
description = ""
|
||||||
|
summary = data.get("summary")
|
||||||
|
if summary:
|
||||||
|
if isinstance(summary, dict):
|
||||||
|
description = _parse_summary_doc(summary).strip()
|
||||||
|
elif isinstance(summary, str):
|
||||||
|
description = summary.strip()
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
for t in data.get("tags") or []:
|
||||||
|
name = (t.get("name") or t.get("label") or "").strip()
|
||||||
|
if name:
|
||||||
|
tags.append(name)
|
||||||
|
|
||||||
|
return cover_url, description, tags
|
||||||
|
|
||||||
|
cover_url, description, tags = _extract_from_data(manga_api_data)
|
||||||
|
|
||||||
|
# Если хотя бы одного поля нет — делаем явный supplementary-запрос
|
||||||
|
if not cover_url or not description or not tags:
|
||||||
|
slug = _manga_slug_from_url(manga_url)
|
||||||
|
referer = _base_url(manga_url) + "/"
|
||||||
|
api_url = (
|
||||||
|
f"https://api.cdnlibs.org/api/manga/{slug}"
|
||||||
|
"?fields[]=summary&fields[]=tags&fields[]=cover"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers: dict = {"Referer": referer, "Accept": "application/json"}
|
||||||
|
if auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
resp = await page.context.request.get(api_url, headers=headers)
|
||||||
|
if resp.ok:
|
||||||
|
body = await resp.body()
|
||||||
|
data = _json.loads(body).get("data", {})
|
||||||
|
extra_cover, extra_desc, extra_tags = _extract_from_data(data)
|
||||||
|
if not cover_url:
|
||||||
|
cover_url = extra_cover
|
||||||
|
if not description:
|
||||||
|
description = extra_desc
|
||||||
|
if not tags:
|
||||||
|
tags = extra_tags
|
||||||
|
logger.debug("Supplementary API: cover={}, desc_len={}, tags={}",
|
||||||
|
bool(cover_url), len(description), len(tags))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Supplementary API error: {}", e)
|
||||||
|
|
||||||
|
return cover_url, description, tags
|
||||||
|
|
||||||
|
|
||||||
|
async def _detect_server(page: Page, servers_list: list[str]) -> str:
|
||||||
|
"""Определяет CDN-сервер из img src на странице или из constants API."""
|
||||||
|
try:
|
||||||
|
imgs = await page.evaluate("""() =>
|
||||||
|
Array.from(document.querySelectorAll('img')).map(i => i.src)
|
||||||
|
.filter(s => s && /https?:\\/\\//.test(s) && /\\.(png|jpg|webp)/i.test(s))
|
||||||
|
""")
|
||||||
|
for img_src in imgs:
|
||||||
|
m = re.match(r"(https?://[^/]+)", img_src)
|
||||||
|
if m:
|
||||||
|
srv = m.group(1)
|
||||||
|
if any(pat in srv for pat in ["mixlib", "imglib", "imgslib"]):
|
||||||
|
return srv
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if servers_list:
|
||||||
|
return servers_list[0]
|
||||||
|
return "https://img3.mixlib.me"
|
||||||
|
|
||||||
|
|
||||||
|
async def _api_fetch(page: Page, url: str, referer: str = "") -> bytes | None:
|
||||||
|
"""
|
||||||
|
Скачивает изображение через Playwright APIRequestContext.
|
||||||
|
Разделяет cookies с браузерным контекстом, не ограничен CORS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers: dict = {"Accept": "image/png,image/jpeg,image/webp,image/*,*/*"}
|
||||||
|
if referer:
|
||||||
|
headers["Referer"] = referer
|
||||||
|
response = await page.context.request.get(url, headers=headers)
|
||||||
|
if response.ok:
|
||||||
|
body = await response.body()
|
||||||
|
return body if len(body) > 500 else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ext(url: str) -> str:
|
||||||
|
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
ext = m.group(1).lower()
|
||||||
|
return ".jpg" if ext == "jpeg" else f".{ext}"
|
||||||
|
return ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(url: str) -> str:
|
||||||
|
m = re.match(r"(https?://[^/]+)", url)
|
||||||
|
return m.group(1) if m else "https://mangalib.me"
|
||||||
695
src/sources/readmanga.py
Normal file
695
src/sources/readmanga.py
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
"""
|
||||||
|
Адаптер ReadManga: поддерживает readmanga.ru и все его клоны.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from .base import Chapter, MangaInfo
|
||||||
|
|
||||||
|
|
||||||
|
class ReadmangaSource:
|
||||||
|
slug = "readmanga"
|
||||||
|
display_name = "ReadManga"
|
||||||
|
|
||||||
|
# CDN-домены из которых принимаем картинки глав
|
||||||
|
cdn_patterns = ["one-way.work", "staticfa.", "rm.one-way", "cdnmanga", "reimg"]
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Страница манги — список глав
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_manga_info(self, page: Page, url: str) -> Optional[MangaInfo]:
|
||||||
|
"""Открывает страницу манги и возвращает список всех глав."""
|
||||||
|
logger.info("Загружаем страницу манги: {}", url)
|
||||||
|
ok = await _navigate(page, url)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
title_full = await page.title()
|
||||||
|
title_full = re.sub(r"\s*[-–|].*$", "", title_full).strip()
|
||||||
|
|
||||||
|
title_ru = await _extract_ru_title_from_dom(page)
|
||||||
|
if not title_ru:
|
||||||
|
title_ru = _parse_ru_title(title_full)
|
||||||
|
|
||||||
|
logger.info("Манга: {} | ru: {}", title_full, title_ru)
|
||||||
|
|
||||||
|
pub_status = await _extract_pub_status(page)
|
||||||
|
logger.info("Статус выпуска: {}", pub_status)
|
||||||
|
|
||||||
|
description = await _extract_description(page)
|
||||||
|
genres = await _extract_genres(page)
|
||||||
|
tags = await _extract_tags(page)
|
||||||
|
cover_url = await _get_cover_url(page)
|
||||||
|
|
||||||
|
await _expand_chapters(page)
|
||||||
|
chapters = await _extract_chapters(page)
|
||||||
|
if not chapters:
|
||||||
|
chapters = await _extract_chapters_alt(page)
|
||||||
|
|
||||||
|
logger.info("Найдено глав: {}", len(chapters))
|
||||||
|
return MangaInfo(
|
||||||
|
title=title_ru or title_full,
|
||||||
|
url=url,
|
||||||
|
chapters=chapters,
|
||||||
|
pub_status=pub_status,
|
||||||
|
title_ru=title_ru,
|
||||||
|
title_full=title_full,
|
||||||
|
description=description,
|
||||||
|
genres=genres,
|
||||||
|
tags=tags,
|
||||||
|
cover_url=cover_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Скачивание главы
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_chapter_images_and_download(
|
||||||
|
self,
|
||||||
|
page: Page,
|
||||||
|
chapter_url: str,
|
||||||
|
dest_dir: Path,
|
||||||
|
manga_url: Optional[str] = None,
|
||||||
|
on_page: object = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""
|
||||||
|
1. Открывает страницу главы.
|
||||||
|
2. Извлекает список URL из readerInit.
|
||||||
|
3. Перехватывает img-запросы через page.route().
|
||||||
|
4. Пролистывает читалку клавишей ArrowRight.
|
||||||
|
5. Retry для страниц с timeout через JS fetch.
|
||||||
|
"""
|
||||||
|
cdn_patterns = self.cdn_patterns
|
||||||
|
t_start = time.monotonic()
|
||||||
|
ch_id = chapter_url.split("/")[-1]
|
||||||
|
logger.info("[{}] Загружаем главу: {}", ch_id, chapter_url)
|
||||||
|
|
||||||
|
parsed = urlparse(chapter_url)
|
||||||
|
parts = parsed.path.strip("/").split("/")
|
||||||
|
manga_slug = parts[0] if parts else ""
|
||||||
|
referer = manga_url or f"{parsed.scheme}://{parsed.netloc}/{manga_slug}"
|
||||||
|
|
||||||
|
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _base(u: str) -> str:
|
||||||
|
return u.split("?")[0]
|
||||||
|
|
||||||
|
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
|
||||||
|
|
||||||
|
def _is_manga_image(url: str) -> bool:
|
||||||
|
base = _base(url)
|
||||||
|
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
|
||||||
|
return False
|
||||||
|
if "resrmr." in url or "/static/" in url:
|
||||||
|
return False
|
||||||
|
pattern = "|".join(re.escape(p) for p in cdn_patterns)
|
||||||
|
return bool(re.search(pattern, url, re.I))
|
||||||
|
|
||||||
|
captured: dict[str, bytes] = {}
|
||||||
|
route_errors: dict[str, str] = {}
|
||||||
|
route_statuses: dict[str, int] = {}
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
# Имена файлов из readerInit — заполняются после парсинга страницы.
|
||||||
|
# Позволяет перехватывать картинки с незнакомых CDN-доменов (например, при VPN).
|
||||||
|
expected_filenames: set[str] = set()
|
||||||
|
|
||||||
|
async def route_handler(route, request):
|
||||||
|
url = request.url
|
||||||
|
base = _base(url)
|
||||||
|
fname = base.split("/")[-1]
|
||||||
|
if not _is_manga_image(url):
|
||||||
|
# Fallback: домен не в cdn_patterns, но имя файла совпадает с readerInit —
|
||||||
|
# значит CDN сменился (VPN, балансировка). Перехватываем.
|
||||||
|
if not expected_filenames or fname not in expected_filenames:
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
logger.debug("[{}] CDN fallback: {} (unknown domain: {})",
|
||||||
|
ch_id, fname, url.split("/")[2])
|
||||||
|
if BANNER_RE.search(base):
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
async with lock:
|
||||||
|
already = base in captured
|
||||||
|
if already:
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
fname = base.split("/")[-1]
|
||||||
|
try:
|
||||||
|
response = await route.fetch()
|
||||||
|
status = response.status
|
||||||
|
body = await response.body()
|
||||||
|
if body and len(body) > 500 and status in (200, 206):
|
||||||
|
async with lock:
|
||||||
|
if base not in captured:
|
||||||
|
captured[base] = body
|
||||||
|
logger.debug("[{}] ✓ {}: {} байт", ch_id, fname, len(body))
|
||||||
|
if on_page:
|
||||||
|
try:
|
||||||
|
asyncio.ensure_future(on_page(0, 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
async with lock:
|
||||||
|
route_statuses[base] = status
|
||||||
|
if status not in (200, 206):
|
||||||
|
logger.warning("[{}] CDN HTTP {} для '{}' | {}",
|
||||||
|
ch_id, status, fname, base[-70:])
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Слишком мал ответ ({} байт) для '{}'",
|
||||||
|
ch_id, len(body), fname)
|
||||||
|
await route.fulfill(response=response)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e)
|
||||||
|
async with lock:
|
||||||
|
route_errors[base] = err
|
||||||
|
is_timeout = "timeout" in err.lower()
|
||||||
|
logger.warning("[{}] route.fetch {} '{}': {}",
|
||||||
|
ch_id, "timeout" if is_timeout else "ошибка", fname, err[:150])
|
||||||
|
try:
|
||||||
|
await route.continue_()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await page.route("**/*", route_handler)
|
||||||
|
|
||||||
|
ok = await _navigate(page, load_url, referer=referer)
|
||||||
|
if not ok:
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
logger.error("[{}] Не удалось открыть главу: {}", ch_id, chapter_url)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(
|
||||||
|
"() => Array.from(document.querySelectorAll('script'))"
|
||||||
|
".some(s => s.textContent.includes('readerInit'))",
|
||||||
|
timeout=15_000,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[{}] readerInit не появился за 15с ({}). DOM-fallback.", ch_id, str(e)[:80])
|
||||||
|
|
||||||
|
image_urls = await _extract_images_from_js(page)
|
||||||
|
if not image_urls:
|
||||||
|
logger.debug("[{}] JS readerInit не дал URL, пробуем DOM-парсинг", ch_id)
|
||||||
|
image_urls = await _extract_images_from_dom(page)
|
||||||
|
if not image_urls:
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
try:
|
||||||
|
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
||||||
|
except Exception:
|
||||||
|
page_info = "?"
|
||||||
|
logger.error("[{}] Список изображений пуст. Страница: {}", ch_id, page_info)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("[{}] Найдено изображений: {}", ch_id, len(image_urls))
|
||||||
|
url_to_idx = {_base(u): i for i, u in enumerate(image_urls)}
|
||||||
|
filename_to_idx = {_base(u).split("/")[-1]: i for i, u in enumerate(image_urls)}
|
||||||
|
total = len(image_urls)
|
||||||
|
# Активируем CDN-fallback в route_handler: теперь он знает ожидаемые имена файлов
|
||||||
|
expected_filenames.update(filename_to_idx.keys())
|
||||||
|
|
||||||
|
def _count_matched() -> int:
|
||||||
|
count = 0
|
||||||
|
for base_url in captured:
|
||||||
|
if base_url in url_to_idx or base_url.split("/")[-1] in filename_to_idx:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
stall_count = 0
|
||||||
|
prev_done = -1
|
||||||
|
for i in range(total + 20):
|
||||||
|
done = _count_matched()
|
||||||
|
if done >= total:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await page.keyboard.press("ArrowRight")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
|
||||||
|
break
|
||||||
|
if i % 20 == 19:
|
||||||
|
done = _count_matched()
|
||||||
|
logger.debug("[{}] Пролистано {}, загружено: {}/{}", ch_id, i + 1, done, total)
|
||||||
|
if done == prev_done:
|
||||||
|
stall_count += 1
|
||||||
|
if stall_count >= 3:
|
||||||
|
logger.warning("[{}] Прогресс завис ({}/{}) — прерываем", ch_id, done, total)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
stall_count = 0
|
||||||
|
prev_done = done
|
||||||
|
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
async def _js_fetch(url: str) -> bytes | None:
|
||||||
|
"""Скачивает изображение через JS fetch в контексте браузера."""
|
||||||
|
try:
|
||||||
|
data_b64 = await page.evaluate("""async (url) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {credentials: 'include'});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const buf = await r.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let bin = '';
|
||||||
|
for (let b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}""", url)
|
||||||
|
if data_b64:
|
||||||
|
body = base64.b64decode(data_b64)
|
||||||
|
return body if len(body) > 500 else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Retry 1: timeout-ошибки через JS fetch
|
||||||
|
async with lock:
|
||||||
|
timeout_bases = [u for u, e in route_errors.items()
|
||||||
|
if "timeout" in e.lower() and u not in captured]
|
||||||
|
if timeout_bases:
|
||||||
|
logger.info("[{}] Retry {} страниц с timeout...", ch_id, len(timeout_bases))
|
||||||
|
for retry_base in timeout_bases:
|
||||||
|
async with lock:
|
||||||
|
if retry_base in captured:
|
||||||
|
continue
|
||||||
|
fname = retry_base.split("/")[-1]
|
||||||
|
body = await _js_fetch(retry_base)
|
||||||
|
if body:
|
||||||
|
async with lock:
|
||||||
|
captured[retry_base] = body
|
||||||
|
logger.info("[{}] Retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] Retry null для '{}'", ch_id, fname)
|
||||||
|
|
||||||
|
# Retry 2: не_перехваченные — CDN-домен сменился (VPN, балансировка).
|
||||||
|
# Браузер их загрузил, но route_handler не захватил байты.
|
||||||
|
# Берём URL напрямую из readerInit и достаём через JS fetch.
|
||||||
|
async with lock:
|
||||||
|
captured_fnames = {b.split("/")[-1] for b in captured}
|
||||||
|
unperceived = [
|
||||||
|
_base(u) for u in image_urls
|
||||||
|
if _base(u).split("/")[-1] not in captured_fnames
|
||||||
|
and _base(u) not in route_errors
|
||||||
|
and _base(u) not in route_statuses
|
||||||
|
]
|
||||||
|
if unperceived:
|
||||||
|
logger.info("[{}] JS retry для {} не_перехваченных (CDN-домен?)..",
|
||||||
|
ch_id, len(unperceived))
|
||||||
|
for retry_base in unperceived:
|
||||||
|
async with lock:
|
||||||
|
if retry_base.split("/")[-1] in captured_fnames:
|
||||||
|
continue
|
||||||
|
fname = retry_base.split("/")[-1]
|
||||||
|
body = await _js_fetch(retry_base)
|
||||||
|
if body:
|
||||||
|
async with lock:
|
||||||
|
captured[retry_base] = body
|
||||||
|
captured_fnames.add(fname)
|
||||||
|
logger.info("[{}] CDN retry OK: {} ({} байт)", ch_id, fname, len(body))
|
||||||
|
else:
|
||||||
|
logger.warning("[{}] CDN retry null для '{}'", ch_id, fname)
|
||||||
|
|
||||||
|
await page.unroute("**/*", route_handler)
|
||||||
|
|
||||||
|
done = _count_matched()
|
||||||
|
elapsed = time.monotonic() - t_start
|
||||||
|
logger.info("[{}] Перехвачено: {}/{} за {:.1f}с", ch_id, done, total, elapsed)
|
||||||
|
|
||||||
|
paths: dict[int, Path] = {}
|
||||||
|
unmatched_other: list[str] = []
|
||||||
|
for base_url, body in captured.items():
|
||||||
|
idx = url_to_idx.get(base_url)
|
||||||
|
if idx is None:
|
||||||
|
fname = base_url.split("/")[-1]
|
||||||
|
idx = filename_to_idx.get(fname)
|
||||||
|
if idx is None:
|
||||||
|
if not BANNER_RE.search(base_url):
|
||||||
|
unmatched_other.append(base_url.split("/")[-1])
|
||||||
|
continue
|
||||||
|
ext = _get_ext(base_url)
|
||||||
|
p = dest_dir / f"{idx:04d}{ext}"
|
||||||
|
p.write_bytes(body)
|
||||||
|
paths[idx] = p
|
||||||
|
|
||||||
|
if unmatched_other:
|
||||||
|
logger.debug("[{}] Не совпало с readerInit ({}): {}", ch_id, len(unmatched_other), unmatched_other)
|
||||||
|
|
||||||
|
missing_idxs = [i for i in range(total) if i not in paths]
|
||||||
|
if missing_idxs:
|
||||||
|
missing_files = [_base(image_urls[i]).split("/")[-1] for i in missing_idxs]
|
||||||
|
missing_full = [_base(image_urls[i]) for i in missing_idxs]
|
||||||
|
|
||||||
|
timeout_miss = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] in route_errors
|
||||||
|
and "timeout" in route_errors[missing_full[j]].lower()]
|
||||||
|
http_miss = [f"{missing_files[j]}(HTTP {route_statuses.get(missing_full[j], '?')})"
|
||||||
|
for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] in route_statuses]
|
||||||
|
unrcv = [missing_files[j] for j, i in enumerate(missing_idxs)
|
||||||
|
if missing_full[j] not in route_errors
|
||||||
|
and missing_full[j] not in route_statuses]
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
if timeout_miss:
|
||||||
|
reasons.append(f"timeout×{len(timeout_miss)}: {timeout_miss}")
|
||||||
|
if http_miss:
|
||||||
|
reasons.append(f"HTTP-err×{len(http_miss)}: {http_miss}")
|
||||||
|
if unrcv:
|
||||||
|
reasons.append(f"не_перехвачено×{len(unrcv)}: {unrcv}")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"[{}] Пропущено {}/{} стр. | №: {} | причины: {}",
|
||||||
|
ch_id, len(missing_idxs), total,
|
||||||
|
[i + 1 for i in missing_idxs],
|
||||||
|
" | ".join(reasons) if reasons else "неизвестно",
|
||||||
|
)
|
||||||
|
|
||||||
|
return [paths[i] for i in sorted(paths.keys())]
|
||||||
|
|
||||||
|
async def get_chapter_page_count(
|
||||||
|
self, page: Page, chapter_url: str, manga_url: Optional[str] = None
|
||||||
|
) -> int:
|
||||||
|
"""Открывает главу и возвращает количество страниц без скачивания изображений."""
|
||||||
|
load_url = chapter_url + ("?mtr=1" if "?" not in chapter_url else "&mtr=1")
|
||||||
|
ok = await _navigate(page, load_url)
|
||||||
|
if not ok:
|
||||||
|
return 0
|
||||||
|
urls = await _extract_images_from_js(page)
|
||||||
|
if not urls:
|
||||||
|
urls = await _extract_images_from_dom(page)
|
||||||
|
return len(urls)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Вспомогательные функции (приватные)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _navigate(page: Page, url: str, retries: int = 3,
|
||||||
|
referer: str | None = None) -> bool:
|
||||||
|
if referer is None:
|
||||||
|
p = urlparse(url)
|
||||||
|
referer = f"{p.scheme}://{p.netloc}/"
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded",
|
||||||
|
timeout=60_000, referer=referer)
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
logger.warning("Попытка {}/{}: HTTP {}", attempt, retries, resp.status)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Попытка {}/{}: {}", attempt, retries, e)
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_ru_title_from_dom(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const selectors = [
|
||||||
|
'.names .name', 'h1.manga-title', 'h1 .name', '.name-block .name',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ru_title(full_title: str) -> str:
|
||||||
|
t = full_title.strip()
|
||||||
|
t = re.sub(r'^Манга\s+', '', t).strip()
|
||||||
|
t = re.split(r'\s*[\(\[]', t)[0].strip()
|
||||||
|
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
|
||||||
|
words = t.split()
|
||||||
|
result = []
|
||||||
|
for w in words:
|
||||||
|
if re.search(r'[а-яёА-ЯЁ]', w):
|
||||||
|
result.append(w)
|
||||||
|
elif re.search(r'[a-zA-Z]', w):
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if result:
|
||||||
|
result.append(w)
|
||||||
|
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
|
||||||
|
result.pop()
|
||||||
|
if result:
|
||||||
|
t = ' '.join(result)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_pub_status(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const statusSelectors = [
|
||||||
|
'.elem_status .value', '.manga-info .status',
|
||||||
|
'[class*="status"] .value', '.property .status',
|
||||||
|
];
|
||||||
|
for (const sel of statusSelectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el) {
|
||||||
|
const t = el.textContent.toLowerCase();
|
||||||
|
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
||||||
|
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
|
||||||
|
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
|
||||||
|
if (bodyText.includes('продолжается')) return 'ongoing';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or "unknown"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_description(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
// Приоритетный селектор — новый сайт ReadManga
|
||||||
|
const crDesc = document.querySelector('.cr-description__content');
|
||||||
|
if (crDesc) {
|
||||||
|
const parts = [];
|
||||||
|
crDesc.querySelectorAll('p, span, div').forEach(el => {
|
||||||
|
const t = el.textContent.trim();
|
||||||
|
if (t) parts.push(t);
|
||||||
|
});
|
||||||
|
if (parts.length) return parts.join(' ');
|
||||||
|
const t = crDesc.textContent.trim();
|
||||||
|
if (t) return t;
|
||||||
|
}
|
||||||
|
const selectors = [
|
||||||
|
'.manga-description', '.elem_descr .value',
|
||||||
|
'#tab-description .description-text', '.description',
|
||||||
|
'[itemprop="description"]',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()[:2000]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_tags(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const crTags = document.querySelector('.cr-tags');
|
||||||
|
if (crTags) {
|
||||||
|
const els = crTags.querySelectorAll('a, span, li');
|
||||||
|
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
||||||
|
const t = crTags.textContent.trim();
|
||||||
|
if (t) return t.split(/[,;]/).map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cover_url(page: Page) -> str:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const wrapper = document.querySelector('.cr-hero-poster-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
if (img) return img.src || img.dataset.src || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return (result or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_genres(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const selectors = [
|
||||||
|
'.elem_genre .value a', '.genres a',
|
||||||
|
'[itemprop="genre"]', '.genre-list a',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const els = document.querySelectorAll(sel);
|
||||||
|
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _expand_chapters(page: Page):
|
||||||
|
for sel in ["a.chapter-link.all", "button:has-text('Все главы')", "a:has-text('Все главы')"]:
|
||||||
|
try:
|
||||||
|
el = page.locator(sel).first
|
||||||
|
if await el.is_visible(timeout=2000):
|
||||||
|
await el.click()
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_chapters(page: Page) -> list[Chapter]:
|
||||||
|
rows = await page.query_selector_all("#chapters-list tr.item-row")
|
||||||
|
chapters = []
|
||||||
|
for row in rows:
|
||||||
|
link = await row.query_selector("td[class*='item-title'] a")
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
href = await link.get_attribute("href") or ""
|
||||||
|
text = (await link.inner_text()).strip()
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
td = await row.query_selector("td[data-num]")
|
||||||
|
vol = int((await td.get_attribute("data-vol") or "0")) if td else 0
|
||||||
|
num_raw = int((await td.get_attribute("data-num") or "0")) if td else 0
|
||||||
|
number = num_raw / 10.0
|
||||||
|
full_url = href if href.startswith("http") else _base_url(page.url) + href
|
||||||
|
chapters.append(Chapter(title=text, url=full_url, number=number, volume=vol))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_chapters_alt(page: Page) -> list[Chapter]:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="/vol"]'));
|
||||||
|
return links.map(a => ({ href: a.href, text: a.textContent.trim() }))
|
||||||
|
.filter(x => x.href && x.text);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return [Chapter(title=x["text"], url=x["href"],
|
||||||
|
number=_parse_num(x["text"]), volume=_parse_vol(x["text"]))
|
||||||
|
for x in result]
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_images_from_js(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
for (const s of document.querySelectorAll('script')) {
|
||||||
|
const text = s.textContent || '';
|
||||||
|
const mi = text.indexOf('readerInit');
|
||||||
|
if (mi === -1) continue;
|
||||||
|
const ai = text.indexOf('[', mi);
|
||||||
|
if (ai === -1) continue;
|
||||||
|
let depth = 0, end = -1;
|
||||||
|
for (let i = ai; i < text.length; i++) {
|
||||||
|
if (text[i] === '[') depth++;
|
||||||
|
else if (text[i] === ']') { depth--; if (!depth) { end = i+1; break; } }
|
||||||
|
}
|
||||||
|
if (end === -1) continue;
|
||||||
|
try {
|
||||||
|
const arr = eval(text.slice(ai, end));
|
||||||
|
if (Array.isArray(arr) && arr.length)
|
||||||
|
return arr.map(item => Array.isArray(item) && item.length >= 3
|
||||||
|
? item[0] + item[2] : null).filter(Boolean);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
if result:
|
||||||
|
logger.debug("JS readerInit нашёл {} изображений", len(result))
|
||||||
|
return result or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("JS-метод не сработал: {}", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_images_from_dom(page: Page) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
for (const sel of ['img.manga-page', '.page-image img', '#mangaReader img', 'img[data-src]']) {
|
||||||
|
const found = Array.from(document.querySelectorAll(sel));
|
||||||
|
if (found.length) return found.map(i => i.src || i.dataset.src).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
return result or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ext(url: str) -> str:
|
||||||
|
m = re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
ext = m.group(1).lower()
|
||||||
|
return ".jpg" if ext == "jpeg" else f".{ext}"
|
||||||
|
return ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(url: str) -> str:
|
||||||
|
m = re.match(r"(https?://[^/]+)", url)
|
||||||
|
return m.group(1) if m else "https://readmanga.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_num(text: str) -> float:
|
||||||
|
m = re.search(r"[\d]+(?:[.,]\d+)?", text.replace(",", "."))
|
||||||
|
return float(m.group()) if m else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vol(text: str) -> int:
|
||||||
|
m = re.search(r"Том\s+(\d+)", text, re.IGNORECASE)
|
||||||
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
497
src/state.py
497
src/state.py
@@ -1,14 +1,51 @@
|
|||||||
"""
|
"""
|
||||||
Хранение состояния скачивания в SQLite.
|
Хранение состояния скачивания в SQLite.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
DB_PATH = Path("/app/state/progress.db")
|
DB_PATH = Path("/app/state/progress.db")
|
||||||
|
|
||||||
|
# Домены ReadManga по умолчанию (сидинг при первом запуске)
|
||||||
|
_DEFAULT_READMANGA_DOMAINS = [
|
||||||
|
"readmanga.ru",
|
||||||
|
"readmanga.live",
|
||||||
|
"readmanga.me",
|
||||||
|
"readmanga.io",
|
||||||
|
"3.readmanga.ru",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Домены MangaLib по умолчанию (сидинг при первом запуске)
|
||||||
|
_DEFAULT_MANGALIB_DOMAINS = [
|
||||||
|
"mangalib.me",
|
||||||
|
"mangalib.org",
|
||||||
|
"hentailib.me",
|
||||||
|
"yaoilib.me",
|
||||||
|
"readlib.net",
|
||||||
|
]
|
||||||
|
|
||||||
|
_ALLOWED_FMTS = frozenset({"cbz", "pdf", "epub"})
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_domain(url: str) -> str:
|
||||||
|
"""Извлекает домен без www."""
|
||||||
|
try:
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class StateDB:
|
class StateDB:
|
||||||
def __init__(self, db_path: Path = DB_PATH):
|
def __init__(self, db_path: Path = DB_PATH):
|
||||||
@@ -35,7 +72,11 @@ class StateDB:
|
|||||||
added_at TEXT,
|
added_at TEXT,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
finished_at TEXT
|
finished_at TEXT,
|
||||||
|
folder_name TEXT,
|
||||||
|
source_id INTEGER REFERENCES sources(id),
|
||||||
|
added_by INTEGER REFERENCES users(id),
|
||||||
|
last_error TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
@@ -68,6 +109,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"),
|
||||||
@@ -76,9 +152,17 @@ class StateDB:
|
|||||||
("mangas", "title_full", "TEXT"),
|
("mangas", "title_full", "TEXT"),
|
||||||
("mangas", "pub_status", "TEXT DEFAULT 'unknown'"),
|
("mangas", "pub_status", "TEXT DEFAULT 'unknown'"),
|
||||||
("mangas", "auto_update", "INTEGER DEFAULT 0"),
|
("mangas", "auto_update", "INTEGER DEFAULT 0"),
|
||||||
("mangas", "last_checked_at", "TEXT"),
|
("mangas", "last_checked_at","TEXT"),
|
||||||
("mangas", "started_at", "TEXT"),
|
("mangas", "started_at", "TEXT"),
|
||||||
("mangas", "finished_at", "TEXT"),
|
("mangas", "finished_at", "TEXT"),
|
||||||
|
("mangas", "folder_name", "TEXT"),
|
||||||
|
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
||||||
|
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
|
||||||
|
("mangas", "last_error", "TEXT"),
|
||||||
|
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("mangas", "description", "TEXT"),
|
||||||
|
("mangas", "tags", "TEXT"),
|
||||||
|
("mangas", "cover_url", "TEXT"),
|
||||||
]
|
]
|
||||||
for table, col, typedef in migrations:
|
for table, col, typedef in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -87,27 +171,244 @@ class StateDB:
|
|||||||
pass
|
pass
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def sync_sources(self, registry) -> None:
|
||||||
|
"""
|
||||||
|
Синхронизирует таблицу sources с реестром из кода.
|
||||||
|
Вызывается при старте приложения.
|
||||||
|
При первом запуске создаёт записи и засеивает домены ReadManga.
|
||||||
|
"""
|
||||||
|
from loguru import logger
|
||||||
|
for source in registry.all_sources():
|
||||||
|
existing = self.conn.execute(
|
||||||
|
"SELECT id, display_name FROM sources WHERE slug=?", (source.slug,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO sources (slug, display_name, settings, created_at) VALUES (?,?,?,?)",
|
||||||
|
(source.slug, source.display_name, "{}", _now())
|
||||||
|
)
|
||||||
|
logger.info("Источник добавлен в БД: {} ({})", source.display_name, source.slug)
|
||||||
|
else:
|
||||||
|
if existing["display_name"] != source.display_name:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sources SET display_name=? WHERE slug=?",
|
||||||
|
(source.display_name, source.slug)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# Сидинг доменов ReadManga при первом запуске
|
||||||
|
rm = self.conn.execute("SELECT id FROM sources WHERE slug='readmanga'").fetchone()
|
||||||
|
if rm:
|
||||||
|
count = self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (rm["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
for domain in _DEFAULT_READMANGA_DOMAINS:
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
|
||||||
|
(rm["id"], domain)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info("Сидинг доменов ReadManga: {} доменов", len(_DEFAULT_READMANGA_DOMAINS))
|
||||||
|
|
||||||
|
# Сидинг доменов MangaLib при первом запуске
|
||||||
|
ml = self.conn.execute("SELECT id FROM sources WHERE slug='mangalib'").fetchone()
|
||||||
|
if ml:
|
||||||
|
count = self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM source_domains WHERE source_id=?", (ml["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
for domain in _DEFAULT_MANGALIB_DOMAINS:
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
|
||||||
|
(ml["id"], domain)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.conn.commit()
|
||||||
|
logger.info("Сидинг доменов MangaLib: {} доменов", len(_DEFAULT_MANGALIB_DOMAINS))
|
||||||
|
|
||||||
|
# Логируем источники в БД без кода (не в реестре)
|
||||||
|
known_slugs = set(registry.all_slugs())
|
||||||
|
db_slugs = [r["slug"] for r in self.conn.execute("SELECT slug FROM sources").fetchall()]
|
||||||
|
for slug in db_slugs:
|
||||||
|
if slug not in known_slugs:
|
||||||
|
logger.warning("Источник '{}' есть в БД, но отсутствует в реестре — манги недоступны", slug)
|
||||||
|
|
||||||
|
def migrate_manga_sources(self) -> int:
|
||||||
|
"""
|
||||||
|
Авто-миграция: проставляет source_id для манг с source_id IS NULL.
|
||||||
|
Определяет источник по домену URL через source_domains.
|
||||||
|
Возвращает количество обновлённых манг.
|
||||||
|
"""
|
||||||
|
nulls = self.conn.execute(
|
||||||
|
"SELECT url FROM mangas WHERE source_id IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
updated = 0
|
||||||
|
for row in nulls:
|
||||||
|
url = row["url"]
|
||||||
|
domain = _extract_domain(url)
|
||||||
|
source_row = self.get_source_by_domain(domain)
|
||||||
|
if source_row:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET source_id=? WHERE url=?",
|
||||||
|
(source_row["id"], url)
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
if updated:
|
||||||
|
self.conn.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
# ── Sources ───────────────────────────────────
|
||||||
|
|
||||||
|
def get_source_by_id(self, source_id: int) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_source_by_slug(self, slug: str) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_source_by_domain(self, domain: str) -> Optional[dict]:
|
||||||
|
"""Возвращает запись source по домену (через source_domains JOIN)."""
|
||||||
|
row = self.conn.execute("""
|
||||||
|
SELECT s.* FROM sources s
|
||||||
|
JOIN source_domains sd ON sd.source_id = s.id
|
||||||
|
WHERE sd.domain=?
|
||||||
|
""", (domain.lower(),)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_all_sources(self) -> list[dict]:
|
||||||
|
"""Возвращает все источники с вложенным списком доменов."""
|
||||||
|
sources = self.conn.execute("SELECT * FROM sources ORDER BY id").fetchall()
|
||||||
|
result = []
|
||||||
|
for s in sources:
|
||||||
|
s_dict = dict(s)
|
||||||
|
domains = self.conn.execute(
|
||||||
|
"SELECT domain FROM source_domains WHERE source_id=? ORDER BY domain",
|
||||||
|
(s["id"],)
|
||||||
|
).fetchall()
|
||||||
|
s_dict["domains"] = [d["domain"] for d in domains]
|
||||||
|
try:
|
||||||
|
s_dict["settings"] = json.loads(s_dict.get("settings") or "{}")
|
||||||
|
except Exception:
|
||||||
|
s_dict["settings"] = {}
|
||||||
|
result.append(s_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def add_domain(self, source_id: int, domain: str) -> bool:
|
||||||
|
"""Добавляет домен к источнику. Возвращает False если уже существует."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO source_domains (source_id, domain) VALUES (?,?)",
|
||||||
|
(source_id, domain)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_domain(self, source_id: int, domain: str) -> bool:
|
||||||
|
"""Удаляет домен у источника. Возвращает True если удалён."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM source_domains WHERE source_id=? AND domain=?",
|
||||||
|
(source_id, domain.lower())
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def set_manga_source(self, manga_url: str, source_id: int) -> None:
|
||||||
|
"""Меняет источник у манги."""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET source_id=?, updated_at=? WHERE url=?",
|
||||||
|
(source_id, _now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def reset_failed_chapters(self, manga_url: str) -> int:
|
||||||
|
"""Сбрасывает failed и partial главы в pending. Возвращает количество."""
|
||||||
|
now = _now()
|
||||||
|
c1 = self.conn.execute(
|
||||||
|
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? "
|
||||||
|
"WHERE manga_url=? AND status='failed'",
|
||||||
|
(now, manga_url)
|
||||||
|
).rowcount
|
||||||
|
c2 = self.conn.execute(
|
||||||
|
"""UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?
|
||||||
|
WHERE manga_url=? AND status='done'
|
||||||
|
AND pages_total > 0 AND pages_done < pages_total""",
|
||||||
|
(now, manga_url)
|
||||||
|
).rowcount
|
||||||
|
self.conn.commit()
|
||||||
|
return c1 + c2
|
||||||
|
|
||||||
|
def count_mangas_by_source_domain(self, domain: str) -> int:
|
||||||
|
"""Считает манги с указанным доменом (для предупреждений в UI)."""
|
||||||
|
source = self.get_source_by_domain(domain)
|
||||||
|
if not source:
|
||||||
|
return 0
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM mangas WHERE source_id=?", (source["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
# ── Mangas ────────────────────────────────────
|
# ── Mangas ────────────────────────────────────
|
||||||
|
|
||||||
def add_manga(self, url: str, fmt: str = "cbz") -> bool:
|
def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None,
|
||||||
|
added_by: Optional[int] = None) -> bool:
|
||||||
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
||||||
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
return False
|
return False
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
INSERT INTO mangas (url, format, status, added_at, updated_at)
|
INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at)
|
||||||
VALUES (?, ?, 'queued', ?, ?)
|
VALUES (?, ?, 'queued', ?, ?, ?, ?)
|
||||||
""", (url, fmt, _now(), _now()))
|
""", (url, fmt, source_id, added_by, _now(), _now()))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update_manga_info(self, url: str, title: str, chapters_total: int,
|
def update_manga_info(self, url: str, title: str, chapters_total: int,
|
||||||
title_ru: str = "", title_full: str = "",
|
title_ru: str = "", title_full: str = "",
|
||||||
pub_status: str = "unknown"):
|
pub_status: str = "unknown",
|
||||||
|
description: str = "", tags: str = "",
|
||||||
|
cover_url: str = ""):
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?,
|
UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?,
|
||||||
chapters_total=?, updated_at=? WHERE url=?
|
chapters_total=?, updated_at=?,
|
||||||
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url))
|
description=?, tags=?, cover_url=?
|
||||||
|
WHERE url=?
|
||||||
|
""", (title, title_ru, title_full, pub_status, chapters_total, _now(),
|
||||||
|
description or None, tags or None, cover_url or None, url))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def set_folder_name(self, url: str, folder_name: str):
|
||||||
|
"""Установить кастомное имя папки для манги."""
|
||||||
|
self.conn.execute("""
|
||||||
|
UPDATE mangas SET folder_name=?, updated_at=? WHERE url=?
|
||||||
|
""", (folder_name, _now(), url))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_manga_meta_fields(self, url: str, title_ru: str = None,
|
||||||
|
title_full: str = None):
|
||||||
|
"""Обновить пользовательские метаданные манги (название серии)."""
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
if title_ru is not None:
|
||||||
|
fields.extend(["title_ru=?", "title=?"])
|
||||||
|
params.extend([title_ru, title_ru])
|
||||||
|
if title_full is not None:
|
||||||
|
fields.append("title_full=?")
|
||||||
|
params.append(title_full)
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
fields.append("updated_at=?")
|
||||||
|
params.append(_now())
|
||||||
|
params.append(url)
|
||||||
|
self.conn.execute(f"UPDATE mangas SET {', '.join(fields)} WHERE url=?", params)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def set_auto_update(self, url: str, enabled: bool):
|
def set_auto_update(self, url: str, enabled: bool):
|
||||||
@@ -128,6 +429,26 @@ class StateDB:
|
|||||||
""", (status, _now(), url))
|
""", (status, _now(), url))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def set_manga_last_error(self, manga_url: str, error: Optional[str]) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE mangas SET last_error=?, updated_at=? WHERE url=?",
|
||||||
|
(error, _now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_mangas_by_source(self, source_id: int) -> list[dict]:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT url, last_error FROM mangas WHERE source_id=?", (source_id,)
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def update_source_settings(self, source_id: int, settings: dict) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sources SET settings=? WHERE id=?",
|
||||||
|
(json.dumps(settings), source_id)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
def mark_started(self, url: str) -> str:
|
def mark_started(self, url: str) -> str:
|
||||||
"""Записывает время начала загрузки. Возвращает timestamp."""
|
"""Записывает время начала загрузки. Возвращает timestamp."""
|
||||||
ts = _now()
|
ts = _now()
|
||||||
@@ -158,17 +479,21 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def increment_manga_chapters_done(self, url: str):
|
|
||||||
# Оставлен для совместимости, но не используется в воркере
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_manga(self, url: str) -> Optional[dict]:
|
def get_manga(self, url: str) -> Optional[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
WHERE m.url=?
|
||||||
|
""", (url,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def get_all_mangas(self) -> list[dict]:
|
def get_all_mangas(self) -> list[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas ORDER BY added_at DESC")
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
ORDER BY m.added_at DESC
|
||||||
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_manga_format(self, url: str) -> str:
|
def get_manga_format(self, url: str) -> str:
|
||||||
@@ -176,6 +501,57 @@ class StateDB:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return row["format"] if row else "cbz"
|
return row["format"] if row else "cbz"
|
||||||
|
|
||||||
|
def get_chapter_stats(self, manga_url: str) -> dict:
|
||||||
|
"""Returns done/failed/partial chapter counts in a single query."""
|
||||||
|
row = self.conn.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN status='done' THEN 1 END) as done,
|
||||||
|
COUNT(CASE WHEN status='failed' THEN 1 END) as failed,
|
||||||
|
COUNT(CASE WHEN status='done' AND pages_total > 0
|
||||||
|
AND pages_done < pages_total THEN 1 END) as partial
|
||||||
|
FROM chapters WHERE manga_url=?
|
||||||
|
""", (manga_url,)).fetchone()
|
||||||
|
return {"done": row[0], "failed": row[1], "partial": row[2]}
|
||||||
|
|
||||||
|
def reset_all_chapters(self, manga_url: str) -> None:
|
||||||
|
"""Resets ALL chapters to pending (used by force-redownload)."""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?"
|
||||||
|
" WHERE manga_url=?",
|
||||||
|
(_now(), manga_url)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_manga_cascade(self, manga_url: str) -> None:
|
||||||
|
"""Deletes manga and all related chapters and history."""
|
||||||
|
self.conn.execute("DELETE FROM chapters WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM history WHERE manga_url=?", (manga_url,))
|
||||||
|
self.conn.execute("DELETE FROM mangas WHERE url=?", (manga_url,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_chapter_output_paths(self, manga_url: str, old_prefix: str, new_prefix: str) -> None:
|
||||||
|
"""Replaces old_prefix with new_prefix in chapter output paths after folder rename."""
|
||||||
|
chapters = self.get_all_chapters(manga_url)
|
||||||
|
for ch in chapters:
|
||||||
|
for col in ("output_cbz", "output_pdf", "output_epub"):
|
||||||
|
p = ch.get(col)
|
||||||
|
if p and old_prefix in p:
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE chapters SET {col}=?, updated_at=? WHERE chapter_url=?",
|
||||||
|
(p.replace(old_prefix, new_prefix), _now(), ch["chapter_url"])
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_news(self, limit: int = 100) -> list[dict]:
|
||||||
|
"""Returns recently downloaded chapters for the news feed."""
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT h.*, m.title as manga_title, m.title_ru
|
||||||
|
FROM history h LEFT JOIN mangas m ON h.manga_url = m.url
|
||||||
|
WHERE h.event_type IN ('downloaded', 'auto_downloaded')
|
||||||
|
ORDER BY h.created_at DESC LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
# ── Chapters ──────────────────────────────────
|
# ── Chapters ──────────────────────────────────
|
||||||
|
|
||||||
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
||||||
@@ -199,6 +575,8 @@ class StateDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||||
|
if fmt not in _ALLOWED_FMTS:
|
||||||
|
raise ValueError(f"Unknown format: {fmt}")
|
||||||
col = f"output_{fmt}"
|
col = f"output_{fmt}"
|
||||||
self.conn.execute(f"""
|
self.conn.execute(f"""
|
||||||
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||||
@@ -284,10 +662,91 @@ class StateDB:
|
|||||||
""")
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# ── Users ─────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user(self, username: str, hashed_password: str, role: str = "user",
|
||||||
|
is_env_admin: bool = False) -> dict:
|
||||||
|
"""Создаёт пользователя. Возвращает dict без поля password."""
|
||||||
|
self.conn.execute("""
|
||||||
|
INSERT INTO users (username, password, role, is_env_admin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (username, hashed_password, role, 1 if is_env_admin else 0, _now(), _now()))
|
||||||
|
self.conn.commit()
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT id, username, role, is_env_admin, created_at FROM users WHERE username=?",
|
||||||
|
(username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[dict]:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM users WHERE username=?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_all_users(self) -> list[dict]:
|
||||||
|
"""Возвращает всех пользователей без поля password."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT id, username, role, is_env_admin, created_at, updated_at FROM users ORDER BY id"
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def count_admins(self) -> int:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role='admin'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
def update_user(self, user_id: int, **kwargs) -> None:
|
||||||
|
"""Обновляет поля пользователя. Разрешённые поля: username, password, role."""
|
||||||
|
allowed = {"username", "password", "role"}
|
||||||
|
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
updates["updated_at"] = _now()
|
||||||
|
sets = ", ".join(f"{k}=?" for k in updates)
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE users SET {sets} WHERE id=?", [*updates.values(), user_id]
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> None:
|
||||||
|
"""Удаляет пользователя и все его сессии."""
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
|
||||||
|
self.conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── Sessions ──────────────────────────────────
|
||||||
|
|
||||||
|
def create_session(self, token: str, user_id: int, expires_at: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)",
|
||||||
|
(token, user_id, _now(), expires_at)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_session(self, token: str) -> Optional[dict]:
|
||||||
|
"""Возвращает сессию если действующая (не истекла)."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE token=? AND expires_at > ?",
|
||||||
|
(token, _now())
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_session(self, token: str) -> None:
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self) -> int:
|
||||||
|
"""Удаляет истёкшие сессии. Возвращает количество удалённых."""
|
||||||
|
cur = self.conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),))
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
|
|||||||
15
src/utils.py
Normal file
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}"
|
||||||
440
src/worker.py
440
src/worker.py
@@ -3,7 +3,6 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
@@ -11,9 +10,12 @@ from typing import Callable, Optional
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .browser import BrowserManager
|
from .browser import BrowserManager
|
||||||
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter
|
from .sources import registry, get_source_for_url, extract_domain
|
||||||
|
import json as _json
|
||||||
|
from .sources.base import Chapter, MangaInfo, AuthRequiredError
|
||||||
from .exporter import export, MangaMeta
|
from .exporter import export, MangaMeta
|
||||||
from .state import StateDB
|
from .state import StateDB
|
||||||
|
from .utils import safe_name, safe_chapter_name
|
||||||
|
|
||||||
OUTPUT_DIR = Path("/app/output")
|
OUTPUT_DIR = Path("/app/output")
|
||||||
|
|
||||||
@@ -21,15 +23,6 @@ OUTPUT_DIR = Path("/app/output")
|
|||||||
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
CHAPTER_CONCURRENCY = int(os.getenv("CHAPTER_CONCURRENCY", "3"))
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(s: str) -> str:
|
|
||||||
return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80]
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_chapter_name(ch: Chapter) -> str:
|
|
||||||
vol = f"v{ch.volume:02d}_" if ch.volume else ""
|
|
||||||
return f"{vol}ch{ch.number:06.1f}"
|
|
||||||
|
|
||||||
|
|
||||||
async def download_manga(
|
async def download_manga(
|
||||||
url: str,
|
url: str,
|
||||||
fmt: str = "cbz",
|
fmt: str = "cbz",
|
||||||
@@ -61,18 +54,52 @@ async def download_manga(
|
|||||||
started_ts = await db_call(db.mark_started, url)
|
started_ts = await db_call(db.mark_started, url)
|
||||||
await emit({"type": "manga_start", "url": url, "started_at": started_ts})
|
await emit({"type": "manga_start", "url": url, "started_at": started_ts})
|
||||||
|
|
||||||
|
# Резолвим источник
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
# Последний шанс: по source_id в БД
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
if manga_row and manga_row.get("source_id"):
|
||||||
|
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||||
|
if source is None:
|
||||||
|
await db_call(db.update_manga_status, url, "failed")
|
||||||
|
await emit({"type": "source_unknown", "url": url,
|
||||||
|
"error": "Источник не определён. Выберите источник в настройках манги."})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Inject auth token from source DB settings
|
||||||
|
if hasattr(source, "auth_token"):
|
||||||
|
_src_row = await db_call(db.get_source_by_slug, source.slug)
|
||||||
|
if _src_row:
|
||||||
|
_settings_raw = _src_row.get("settings") or "{}"
|
||||||
|
try:
|
||||||
|
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
|
||||||
|
except Exception:
|
||||||
|
_settings = {}
|
||||||
|
source.auth_token = _settings.get("auth_token") or None
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
ctx, info_page = await bm.new_page()
|
ctx, info_page = await bm.new_page()
|
||||||
|
|
||||||
manga = await get_manga_info(info_page, url)
|
try:
|
||||||
|
manga = await source.get_manga_info(info_page, url)
|
||||||
|
except AuthRequiredError as e:
|
||||||
await info_page.close()
|
await info_page.close()
|
||||||
|
await db_call(db.update_manga_status, url, "stopped")
|
||||||
|
await db_call(db.set_manga_last_error, url, f"auth_required:{e.source_slug}")
|
||||||
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
await emit({"type": "auth_required", "url": url,
|
||||||
|
"source_slug": e.source_slug, "finished_at": finished_ts})
|
||||||
|
return
|
||||||
|
|
||||||
if not manga:
|
if not manga:
|
||||||
|
await info_page.close()
|
||||||
await db_call(db.update_manga_status, url, "failed")
|
await db_call(db.update_manga_status, url, "failed")
|
||||||
await emit({"type": "manga_failed", "url": url,
|
await emit({"type": "manga_failed", "url": url,
|
||||||
"error": "Не удалось получить информацию о манге"})
|
"error": "Не удалось получить информацию о манге"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
import json as _json_mod
|
||||||
await db_call(
|
await db_call(
|
||||||
db.update_manga_info,
|
db.update_manga_info,
|
||||||
url,
|
url,
|
||||||
@@ -81,6 +108,9 @@ async def download_manga(
|
|||||||
title_ru=manga.title_ru,
|
title_ru=manga.title_ru,
|
||||||
title_full=manga.title_full,
|
title_full=manga.title_full,
|
||||||
pub_status=manga.pub_status,
|
pub_status=manga.pub_status,
|
||||||
|
description=manga.description,
|
||||||
|
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
|
||||||
|
cover_url=manga.cover_url,
|
||||||
)
|
)
|
||||||
await emit({
|
await emit({
|
||||||
"type": "manga_info",
|
"type": "manga_info",
|
||||||
@@ -92,10 +122,21 @@ async def download_manga(
|
|||||||
"chapters_total": len(manga.chapters),
|
"chapters_total": len(manga.chapters),
|
||||||
})
|
})
|
||||||
|
|
||||||
folder_name = _safe_name(manga.title_ru or manga.title)
|
# Используем кастомное имя папки из БД, если задано
|
||||||
|
_db_manga = await db_call(db.get_manga, url)
|
||||||
|
folder_name = (
|
||||||
|
(_db_manga.get("folder_name") if _db_manga else None)
|
||||||
|
or safe_name(manga.title_ru or manga.title)
|
||||||
|
)
|
||||||
manga_dir = output_dir / folder_name
|
manga_dir = output_dir / folder_name
|
||||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Скачиваем обложку для CBZ-формата (info_page ещё открыта — контекст браузера жив)
|
||||||
|
if manga.cover_url and fmt in ("cbz", "all"):
|
||||||
|
await _download_cover(manga.cover_url, manga_dir, url, info_page)
|
||||||
|
|
||||||
|
await info_page.close()
|
||||||
|
|
||||||
for ch in manga.chapters:
|
for ch in manga.chapters:
|
||||||
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||||
|
|
||||||
@@ -173,22 +214,23 @@ async def download_manga(
|
|||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
tmp_path = Path(tmpdir)
|
tmp_path = Path(tmpdir)
|
||||||
pages_done_count = [0]
|
pages_done = 0
|
||||||
|
|
||||||
async def on_page(page_idx: int, pages_total: int):
|
async def on_page(page_idx: int, pages_total: int):
|
||||||
pages_done_count[0] += 1
|
nonlocal pages_done
|
||||||
|
pages_done += 1
|
||||||
await db_call(db.update_chapter_pages,
|
await db_call(db.update_chapter_pages,
|
||||||
ch.url, pages_total, pages_done_count[0])
|
ch.url, pages_total, pages_done)
|
||||||
await emit({
|
await emit({
|
||||||
"type": "page_done",
|
"type": "page_done",
|
||||||
"url": url,
|
"url": url,
|
||||||
"chapter_url": ch.url,
|
"chapter_url": ch.url,
|
||||||
"page_idx": page_idx,
|
"page_idx": page_idx,
|
||||||
"pages_done": pages_done_count[0],
|
"pages_done": pages_done,
|
||||||
"pages_total": pages_total,
|
"pages_total": pages_total,
|
||||||
})
|
})
|
||||||
|
|
||||||
image_paths = await get_chapter_images_and_download(
|
image_paths = await source.get_chapter_images_and_download(
|
||||||
ch_page, ch.url,
|
ch_page, ch.url,
|
||||||
dest_dir=tmp_path,
|
dest_dir=tmp_path,
|
||||||
manga_url=url,
|
manga_url=url,
|
||||||
@@ -206,7 +248,7 @@ async def download_manga(
|
|||||||
"chapter_url": ch.url})
|
"chapter_url": ch.url})
|
||||||
return
|
return
|
||||||
|
|
||||||
ch_name = _safe_chapter_name(ch)
|
ch_name = safe_chapter_name(ch)
|
||||||
ch_meta = MangaMeta(
|
ch_meta = MangaMeta(
|
||||||
series=manga.title_ru or manga.title,
|
series=manga.title_ru or manga.title,
|
||||||
series_full=manga.title_full or "",
|
series_full=manga.title_full or "",
|
||||||
@@ -218,6 +260,7 @@ async def download_manga(
|
|||||||
source_url=url,
|
source_url=url,
|
||||||
summary=manga.description,
|
summary=manga.description,
|
||||||
genre=", ".join(manga.genres) if manga.genres else "",
|
genre=", ".join(manga.genres) if manga.genres else "",
|
||||||
|
tags=", ".join(manga.tags) if manga.tags else "",
|
||||||
)
|
)
|
||||||
for f in formats:
|
for f in formats:
|
||||||
out_file = manga_dir / f"{ch_name}.{f}"
|
out_file = manga_dir / f"{ch_name}.{f}"
|
||||||
@@ -256,6 +299,8 @@ async def download_manga(
|
|||||||
"chapters_total": len(manga.chapters),
|
"chapters_total": len(manga.chapters),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except AuthRequiredError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
||||||
@@ -271,14 +316,70 @@ async def download_manga(
|
|||||||
tasks = [process_chapter(ch) for ch in to_download]
|
tasks = [process_chapter(ch) for ch in to_download]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Логируем неожиданные исключения из gather
|
# Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
|
||||||
|
auth_slug = None
|
||||||
for ch, res in zip(to_download, results):
|
for ch, res in zip(to_download, results):
|
||||||
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
if isinstance(res, AuthRequiredError):
|
||||||
|
auth_slug = res.source_slug
|
||||||
|
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
||||||
ch.volume, ch.number, ch.title, res,
|
ch.volume, ch.number, ch.title, res,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if auth_slug:
|
||||||
|
await db_call(db.update_manga_status, url, "stopped")
|
||||||
|
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
|
||||||
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
await emit({"type": "auth_required", "url": url,
|
||||||
|
"source_slug": auth_slug, "finished_at": finished_ts})
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Автоповтор неудачных глав (до 3 раз) ─────────────────────
|
||||||
|
MAX_AUTO_RETRIES = 3
|
||||||
|
for retry_attempt in range(1, MAX_AUTO_RETRIES + 1):
|
||||||
|
stats = await db_call(db.get_chapter_stats, url)
|
||||||
|
if stats["failed"] + stats["partial"] == 0:
|
||||||
|
break
|
||||||
|
failed_count = stats["failed"] + stats["partial"]
|
||||||
|
logger.info(
|
||||||
|
"Автоповтор {}/{}: {} неудачных/частичных глав для {}",
|
||||||
|
retry_attempt, MAX_AUTO_RETRIES, failed_count, url,
|
||||||
|
)
|
||||||
|
await emit({
|
||||||
|
"type": "retry_errors_auto",
|
||||||
|
"url": url,
|
||||||
|
"attempt": retry_attempt,
|
||||||
|
"max_attempts": MAX_AUTO_RETRIES,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
})
|
||||||
|
await db_call(db.reset_failed_chapters, url)
|
||||||
|
all_ch_rows = await db_call(db.get_all_chapters, url)
|
||||||
|
pending_urls = {c["chapter_url"] for c in all_ch_rows if c["status"] == "pending"}
|
||||||
|
retry_chapters = [ch for ch in manga.chapters if ch.url in pending_urls]
|
||||||
|
if not retry_chapters:
|
||||||
|
break
|
||||||
|
retry_results = await asyncio.gather(
|
||||||
|
*[process_chapter(ch) for ch in retry_chapters],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
auth_slug = None
|
||||||
|
for ch, res in zip(retry_chapters, retry_results):
|
||||||
|
if isinstance(res, AuthRequiredError):
|
||||||
|
auth_slug = res.source_slug
|
||||||
|
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||||
|
logger.exception(
|
||||||
|
"retry {}: необработанное исключение Т{} Гл.{} '{}': {}",
|
||||||
|
retry_attempt, ch.volume, ch.number, ch.title, res,
|
||||||
|
)
|
||||||
|
if auth_slug:
|
||||||
|
await db_call(db.update_manga_status, url, "stopped")
|
||||||
|
await db_call(db.set_manga_last_error, url, f"auth_required:{auth_slug}")
|
||||||
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
|
await emit({"type": "auth_required", "url": url,
|
||||||
|
"source_slug": auth_slug, "finished_at": finished_ts})
|
||||||
|
return
|
||||||
|
|
||||||
real_done = await db_call(db.sync_chapters_done, url)
|
real_done = await db_call(db.sync_chapters_done, url)
|
||||||
await db_call(db.update_manga_status, url, "done")
|
await db_call(db.update_manga_status, url, "done")
|
||||||
finished_ts = await db_call(db.mark_finished, url)
|
finished_ts = await db_call(db.mark_finished, url)
|
||||||
@@ -293,6 +394,8 @@ async def download_manga(
|
|||||||
await ctx.close()
|
await ctx.close()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
# Прокидываем выше — обработка статуса уже сделана API-эндпоинтом
|
||||||
|
# (или воркером в _queue_worker_loop).
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Manga worker error {}: {}", url, e)
|
logger.error("Manga worker error {}: {}", url, e)
|
||||||
@@ -303,6 +406,244 @@ async def download_manga(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_manga(
|
||||||
|
url: str,
|
||||||
|
output_dir: Path = OUTPUT_DIR,
|
||||||
|
on_event=None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Проверяет целостность скачанной манги, сравнивая с сайтом.
|
||||||
|
|
||||||
|
- Получает актуальный список глав с сайта
|
||||||
|
- Добавляет новые главы в БД
|
||||||
|
- Для скачанных глав: проверяет наличие файлов и количество страниц
|
||||||
|
- Возвращает dict с chapters_to_redownload и статистикой
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def emit(event: dict):
|
||||||
|
if on_event:
|
||||||
|
try:
|
||||||
|
await on_event(event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("on_event error: {}", e)
|
||||||
|
|
||||||
|
db = StateDB()
|
||||||
|
db_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def db_call(fn, *args, **kwargs):
|
||||||
|
async with db_lock:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await emit({"type": "validate_started", "url": url})
|
||||||
|
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
if manga_row and manga_row.get("source_id"):
|
||||||
|
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||||
|
if source is None:
|
||||||
|
await emit({"type": "validate_error", "url": url,
|
||||||
|
"error": "Источник не определён. Выберите источник в настройках манги."})
|
||||||
|
return {"ok": False, "chapters_to_redownload": []}
|
||||||
|
|
||||||
|
if hasattr(source, "auth_token"):
|
||||||
|
_src_row = await db_call(db.get_source_by_slug, source.slug)
|
||||||
|
if _src_row:
|
||||||
|
_settings_raw = _src_row.get("settings") or "{}"
|
||||||
|
try:
|
||||||
|
_settings = _json.loads(_settings_raw) if isinstance(_settings_raw, str) else (_settings_raw or {})
|
||||||
|
except Exception:
|
||||||
|
_settings = {}
|
||||||
|
source.auth_token = _settings.get("auth_token") or None
|
||||||
|
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
fmt = (manga_row or {}).get("format", "cbz")
|
||||||
|
fmt_list = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
||||||
|
|
||||||
|
async with BrowserManager(headless=True) as bm:
|
||||||
|
ctx, info_page = await bm.new_page()
|
||||||
|
try:
|
||||||
|
manga = await source.get_manga_info(info_page, url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("validate: get_manga_info ошибка для {}: {}", url, e)
|
||||||
|
await emit({"type": "validate_error", "url": url, "error": str(e)})
|
||||||
|
return {"ok": False, "chapters_to_redownload": []}
|
||||||
|
finally:
|
||||||
|
await info_page.close()
|
||||||
|
|
||||||
|
if not manga:
|
||||||
|
await emit({"type": "validate_error", "url": url,
|
||||||
|
"error": "Не удалось получить информацию о манге с сайта"})
|
||||||
|
return {"ok": False, "chapters_to_redownload": []}
|
||||||
|
|
||||||
|
for ch in manga.chapters:
|
||||||
|
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||||
|
|
||||||
|
all_ch_rows = await db_call(db.get_all_chapters, url)
|
||||||
|
db_chapters = {c["chapter_url"]: c for c in all_ch_rows}
|
||||||
|
|
||||||
|
new_chapters = [
|
||||||
|
ch for ch in manga.chapters
|
||||||
|
if db_chapters.get(ch.url, {}).get("status") == "pending"
|
||||||
|
]
|
||||||
|
|
||||||
|
done_chapters = [
|
||||||
|
ch for ch in manga.chapters
|
||||||
|
if db_chapters.get(ch.url, {}).get("status") == "done"
|
||||||
|
]
|
||||||
|
|
||||||
|
to_redownload: set = set()
|
||||||
|
fast_issues = 0
|
||||||
|
|
||||||
|
for ch in done_chapters:
|
||||||
|
db_ch = db_chapters[ch.url]
|
||||||
|
if db_ch.get("pages_total", 0) > 0 and db_ch.get("pages_done", 0) < db_ch["pages_total"]:
|
||||||
|
to_redownload.add(ch.url)
|
||||||
|
fast_issues += 1
|
||||||
|
continue
|
||||||
|
for f in fmt_list:
|
||||||
|
fpath = db_ch.get(f"output_{f}")
|
||||||
|
if fpath and not Path(fpath).exists():
|
||||||
|
to_redownload.add(ch.url)
|
||||||
|
fast_issues += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
chapters_for_deep = [
|
||||||
|
ch for ch in done_chapters if ch.url not in to_redownload
|
||||||
|
]
|
||||||
|
site_mismatched = 0
|
||||||
|
checked = 0
|
||||||
|
has_page_count = hasattr(source, "get_chapter_page_count")
|
||||||
|
|
||||||
|
if has_page_count and chapters_for_deep:
|
||||||
|
sem = asyncio.Semaphore(2)
|
||||||
|
count_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def check_one(ch: Chapter) -> None:
|
||||||
|
nonlocal checked, site_mismatched
|
||||||
|
async with sem:
|
||||||
|
db_ch = db_chapters[ch.url]
|
||||||
|
ch_page = await ctx.new_page()
|
||||||
|
mismatch = False
|
||||||
|
site_count = 0
|
||||||
|
try:
|
||||||
|
site_count = await source.get_chapter_page_count(
|
||||||
|
ch_page, ch.url, url
|
||||||
|
)
|
||||||
|
except AuthRequiredError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"validate page count Т{} Гл.{}: {}", ch.volume, ch.number, e
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await ch_page.close()
|
||||||
|
|
||||||
|
pages_have = db_ch.get("pages_done", 0)
|
||||||
|
if site_count > 0 and site_count != pages_have:
|
||||||
|
mismatch = True
|
||||||
|
logger.info(
|
||||||
|
"validate: Т{} Гл.{} — сайт {} стр., у нас {} → повтор",
|
||||||
|
ch.volume, ch.number, site_count, pages_have,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with count_lock:
|
||||||
|
checked += 1
|
||||||
|
if mismatch:
|
||||||
|
to_redownload.add(ch.url)
|
||||||
|
site_mismatched += 1
|
||||||
|
|
||||||
|
await emit({
|
||||||
|
"type": "validate_progress",
|
||||||
|
"url": url,
|
||||||
|
"checked": checked,
|
||||||
|
"total": len(chapters_for_deep),
|
||||||
|
"chapter_number": ch.number,
|
||||||
|
"volume": ch.volume,
|
||||||
|
"mismatch": mismatch,
|
||||||
|
"site_count": site_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[check_one(ch) for ch in chapters_for_deep],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
auth_slug = None
|
||||||
|
for res in results:
|
||||||
|
if isinstance(res, AuthRequiredError):
|
||||||
|
auth_slug = res.source_slug
|
||||||
|
elif isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||||
|
logger.exception("validate gather exception: {}", res)
|
||||||
|
if auth_slug:
|
||||||
|
await emit({"type": "validate_error", "url": url,
|
||||||
|
"error": f"auth_required:{auth_slug}"})
|
||||||
|
return {"ok": False, "chapters_to_redownload": []}
|
||||||
|
|
||||||
|
to_redownload_list = list(to_redownload)
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"url": url,
|
||||||
|
"site_chapters": len(manga.chapters),
|
||||||
|
"new_chapters": len(new_chapters),
|
||||||
|
"fast_issues": fast_issues,
|
||||||
|
"site_mismatched": site_mismatched,
|
||||||
|
"total_to_redownload": len(to_redownload_list),
|
||||||
|
"chapters_to_redownload": to_redownload_list,
|
||||||
|
}
|
||||||
|
await emit({
|
||||||
|
"type": "validate_done",
|
||||||
|
**{k: v for k, v in result.items() if k != "chapters_to_redownload"},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("validate_manga {}: {}", url, e)
|
||||||
|
await emit({"type": "validate_error", "url": url, "error": str(e)})
|
||||||
|
return {"ok": False, "chapters_to_redownload": []}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_ext_from_url(url: str) -> str:
|
||||||
|
import re as _re
|
||||||
|
m = _re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", url, _re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
ext = m.group(1).lower()
|
||||||
|
return ".jpg" if ext == "jpeg" else f".{ext}"
|
||||||
|
return ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_cover(cover_url: str, manga_dir: Path, manga_url: str, page) -> Optional[Path]:
|
||||||
|
"""Скачивает обложку в manga_dir/cover.{ext}. Использует существующий Playwright page."""
|
||||||
|
from urllib.parse import urlparse as _urlparse
|
||||||
|
try:
|
||||||
|
parsed = _urlparse(manga_url)
|
||||||
|
referer = f"{parsed.scheme}://{parsed.netloc}/"
|
||||||
|
headers = {
|
||||||
|
"Accept": "image/png,image/jpeg,image/webp,image/*,*/*",
|
||||||
|
"Referer": referer,
|
||||||
|
}
|
||||||
|
response = await page.context.request.get(cover_url, headers=headers)
|
||||||
|
if not response.ok:
|
||||||
|
logger.warning("Обложка: HTTP {} для {}", response.status, cover_url)
|
||||||
|
return None
|
||||||
|
body = await response.body()
|
||||||
|
if len(body) < 500:
|
||||||
|
logger.warning("Обложка: слишком малый ответ ({} байт)", len(body))
|
||||||
|
return None
|
||||||
|
ext = _cover_ext_from_url(cover_url)
|
||||||
|
cover_path = manga_dir / f"cover{ext}"
|
||||||
|
cover_path.write_bytes(body)
|
||||||
|
logger.info("Обложка сохранена: {} ({} байт)", cover_path.name, len(body))
|
||||||
|
return cover_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Ошибка скачивания обложки {}: {}", cover_url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def check_for_updates(
|
async def check_for_updates(
|
||||||
url: str,
|
url: str,
|
||||||
on_event: Optional[Callable] = None,
|
on_event: Optional[Callable] = None,
|
||||||
@@ -319,35 +660,71 @@ async def check_for_updates(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
db = StateDB()
|
db = StateDB()
|
||||||
|
db_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def db_call(fn, *args, **kwargs):
|
||||||
|
async with db_lock:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.set_last_checked(url)
|
await db_call(db.set_last_checked, url)
|
||||||
db.add_history(manga_url=url, event_type="check_started")
|
await db_call(db.add_history, manga_url=url, event_type="check_started")
|
||||||
await emit({"type": "check_started", "url": url})
|
await emit({"type": "check_started", "url": url})
|
||||||
|
|
||||||
|
# Резолвим источник
|
||||||
|
source = get_source_for_url(url, db)
|
||||||
|
if source is None:
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
if manga_row and manga_row.get("source_id"):
|
||||||
|
source = registry.get_by_db_id(manga_row["source_id"], db)
|
||||||
|
if source is None:
|
||||||
|
await emit({"type": "source_unknown", "url": url})
|
||||||
|
return []
|
||||||
|
|
||||||
async with BrowserManager(headless=True) as bm:
|
async with BrowserManager(headless=True) as bm:
|
||||||
_, page = await bm.new_page()
|
_, page = await bm.new_page()
|
||||||
manga = await get_manga_info(page, url)
|
manga = await source.get_manga_info(page, url)
|
||||||
await page.close()
|
|
||||||
if not manga:
|
if not manga:
|
||||||
|
await page.close()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Обновляем pub_status и количество глав
|
import json as _json_mod
|
||||||
db.update_manga_info(
|
# Обновляем pub_status, количество глав и мета-поля
|
||||||
|
await db_call(
|
||||||
|
db.update_manga_info,
|
||||||
url,
|
url,
|
||||||
title=manga.title_ru or manga.title,
|
title=manga.title_ru or manga.title,
|
||||||
chapters_total=len(manga.chapters),
|
chapters_total=len(manga.chapters),
|
||||||
title_ru=manga.title_ru,
|
title_ru=manga.title_ru,
|
||||||
title_full=manga.title_full,
|
title_full=manga.title_full,
|
||||||
pub_status=manga.pub_status,
|
pub_status=manga.pub_status,
|
||||||
|
description=manga.description,
|
||||||
|
tags=_json_mod.dumps(manga.tags, ensure_ascii=False) if manga.tags else "",
|
||||||
|
cover_url=manga.cover_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Обновляем обложку если манга сохраняется как cbz
|
||||||
|
manga_row = await db_call(db.get_manga, url)
|
||||||
|
manga_fmt = (manga_row or {}).get("format", "cbz")
|
||||||
|
if manga.cover_url and manga_fmt in ("cbz", "all"):
|
||||||
|
folder_name = (
|
||||||
|
(manga_row.get("folder_name") if manga_row else None)
|
||||||
|
or safe_name(manga.title_ru or manga.title)
|
||||||
|
)
|
||||||
|
manga_dir = OUTPUT_DIR / folder_name
|
||||||
|
if manga_dir.exists():
|
||||||
|
await _download_cover(manga.cover_url, manga_dir, url, page)
|
||||||
|
|
||||||
|
await page.close()
|
||||||
|
|
||||||
# Находим главы которых ещё нет в БД
|
# Находим главы которых ещё нет в БД
|
||||||
known = {ch["chapter_url"] for ch in db.get_all_chapters(url)}
|
known = {ch["chapter_url"] for ch in await db_call(db.get_all_chapters, url)}
|
||||||
new_chapters = [ch for ch in manga.chapters if ch.url not in known]
|
new_chapters = [ch for ch in manga.chapters if ch.url not in known]
|
||||||
|
|
||||||
for ch in new_chapters:
|
for ch in new_chapters:
|
||||||
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
|
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||||
db.add_history(
|
await db_call(
|
||||||
|
db.add_history,
|
||||||
manga_url=url,
|
manga_url=url,
|
||||||
event_type="new_chapter_found",
|
event_type="new_chapter_found",
|
||||||
chapter_url=ch.url,
|
chapter_url=ch.url,
|
||||||
@@ -363,7 +740,8 @@ async def check_for_updates(
|
|||||||
"chapter_number": ch.number,
|
"chapter_number": ch.number,
|
||||||
})
|
})
|
||||||
|
|
||||||
db.add_history(
|
await db_call(
|
||||||
|
db.add_history,
|
||||||
manga_url=url,
|
manga_url=url,
|
||||||
event_type="check_done",
|
event_type="check_done",
|
||||||
details=f"Найдено новых: {len(new_chapters)}",
|
details=f"Найдено новых: {len(new_chapters)}",
|
||||||
|
|||||||
Reference in New Issue
Block a user