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-стек-технологий)
|
||||
4. [Схема архитектуры](#4-схема-архитектуры)
|
||||
5. [Модули бэкенда](#5-модули-бэкенда)
|
||||
- [auth.py](#authpy)
|
||||
- [browser.py](#browserpy)
|
||||
- [scraper.py](#scraperpy)
|
||||
- [exporter.py](#exporterpy)
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
Приложение скачивает мангу с сайтов типа 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`.
|
||||
|
||||
---
|
||||
@@ -41,6 +42,7 @@ manga/
|
||||
├── src/ # Весь бэкенд-код (Python-пакет)
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # FastAPI-приложение, REST + WebSocket
|
||||
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
|
||||
│ ├── browser.py # Обёртка над Playwright/Chromium
|
||||
│ ├── cli.py # CLI-команды (click)
|
||||
│ ├── downloader.py # (legacy, не используется в web-режиме)
|
||||
@@ -84,6 +86,7 @@ manga/
|
||||
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
||||
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
|
||||
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
|
||||
| Расписание | `croniter==3.0.3` | Парсинг cron-выражений для планировщика |
|
||||
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
|
||||
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
|
||||
|
||||
@@ -99,6 +102,7 @@ manga/
|
||||
┌─────────────────────────────────┐
|
||||
│ FastAPI (api.py) │
|
||||
│ │
|
||||
│ Auth middleware (cookie/session)│
|
||||
│ REST endpoints WebSocket /ws │
|
||||
│ │ │ │
|
||||
│ asyncio.Queue ws_manager │
|
||||
@@ -128,6 +132,25 @@ manga/
|
||||
|
||||
## 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
|
||||
|
||||
**Отвечает за:** запуск и управление Playwright Chromium.
|
||||
@@ -247,8 +270,11 @@ class MangaInfo:
|
||||
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
|
||||
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
|
||||
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
||||
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
|
||||
| `chapters_done` | INTEGER | Денормализованный счётчик¹ |
|
||||
| `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'`.
|
||||
|
||||
@@ -273,13 +299,59 @@ class MangaInfo:
|
||||
| `new_chapter_found` | Найдена новая глава при проверке |
|
||||
| `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`).
|
||||
- `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`.
|
||||
- `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
|
||||
4. update_manga_info() в БД
|
||||
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_download (всё остальное)
|
||||
7. Отправляем chapter_skipped события для to_skip
|
||||
8. asyncio.Semaphore(chapter_concurrency)
|
||||
9. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||
10. sync_chapters_done() → update_manga_status → "done"
|
||||
8. Отправляем chapter_skipped события для to_skip
|
||||
9. asyncio.Semaphore(chapter_concurrency)
|
||||
10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||
11. sync_chapters_done() → update_manga_status → "done"
|
||||
```
|
||||
|
||||
#### `process_chapter(ch)` (внутренняя корутина)
|
||||
@@ -334,7 +409,17 @@ async with sem: # ограничение параллельности
|
||||
|
||||
### 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-соединений
|
||||
```
|
||||
|
||||
#### Вспомогательные функции
|
||||
|
||||
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
|
||||
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
|
||||
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
|
||||
|
||||
#### Жизненный цикл при старте (`startup_event`)
|
||||
|
||||
1. Запускает `queue_worker()` как фоновую Task.
|
||||
2. Запускает `update_scheduler()` как фоновую Task.
|
||||
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь).
|
||||
1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
|
||||
2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
|
||||
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()`
|
||||
|
||||
@@ -356,20 +450,19 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
|
||||
|
||||
#### `update_scheduler()`
|
||||
|
||||
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч):
|
||||
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`.
|
||||
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`.
|
||||
Через 5 минут после старта, затем по расписанию `UPDATE_SCHEDULE` (cron-синтаксис):
|
||||
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
|
||||
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
|
||||
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
|
||||
|
||||
#### `_enrich_manga(m, db)`
|
||||
|
||||
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
|
||||
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не стальная колонка).
|
||||
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
|
||||
- `size_bytes` / `size_human` — размер папки на диске.
|
||||
- `is_active` — есть ли Task в `active_tasks`.
|
||||
- `errors_count` — сумма failed и partial глав.
|
||||
|
||||
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
|
||||
|
||||
---
|
||||
|
||||
### cli.py
|
||||
@@ -414,6 +507,36 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
|
||||
Базовый 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` | Список всех манг с реальными счётчиками |
|
||||
@@ -421,11 +544,21 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
|
||||
| `POST` | `/api/mangas/stop?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/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
|
||||
| `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/history?limit=&manga_url=` | История событий |
|
||||
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
|
||||
@@ -448,6 +581,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `manga_stopped` | `{url}` | Остановлена |
|
||||
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
||||
| `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_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
|
||||
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
|
||||
@@ -456,6 +592,9 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
|
||||
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
||||
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
||||
| `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. Фронтенд
|
||||
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк).
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
|
||||
|
||||
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
||||
|
||||
### Экран входа
|
||||
|
||||
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
|
||||
|
||||
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
|
||||
|
||||
### Архитектура состояния
|
||||
|
||||
```javascript
|
||||
const state = {
|
||||
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
|
||||
chapters: {}, // url → массив глав (загружается по запросу в модалке)
|
||||
currentUser: null, // {id, username, role, is_env_admin}
|
||||
sources: [], // список источников
|
||||
};
|
||||
```
|
||||
|
||||
@@ -485,101 +632,64 @@ const state = {
|
||||
```
|
||||
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
|
||||
│ + live events ──────────► state.mangas[url].* обновляется
|
||||
│ └─ snapshot event ───────► state.mangas = enriched list
|
||||
│ + live events ───────► state.mangas[url].* обновляется
|
||||
│
|
||||
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
|
||||
(перезаписывает snapshot если пришёл раньше)
|
||||
└─ fetch('/api/mangas') ──────────► state.mangas = полный список
|
||||
```
|
||||
|
||||
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
||||
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
||||
|
||||
### Вкладки
|
||||
|
||||
- **Манга** — список всех манг, добавление, управление.
|
||||
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
|
||||
- **История** — все события из таблицы `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)
|
||||
│
|
||||
└── 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.
|
||||
Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие `queue_positions` (не перерендер всего списка, а точечное обновление через `updateMangaRow`). Событие рассылается сервером при каждом изменении состояния очереди.
|
||||
|
||||
---
|
||||
|
||||
@@ -589,8 +699,11 @@ asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
|
||||
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
|
||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
||||
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
|
||||
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
|
||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||
|
||||
### Пути (hardcoded в коде)
|
||||
@@ -630,7 +743,9 @@ ports:
|
||||
|
||||
shm_size: "2gb" # Chromium требует shared memory
|
||||
environment:
|
||||
- UPDATE_INTERVAL_HOURS=6
|
||||
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
|
||||
- AUTH_LOGIN=... # системный администратор
|
||||
- AUTH_PASSWORD=...
|
||||
|
||||
restart: unless-stopped # Автоперезапуск при падении
|
||||
```
|
||||
@@ -651,8 +766,7 @@ docker compose run --rm --entrypoint "" manga \
|
||||
|
||||
После остановки контейнера все данные сохраняются на хосте:
|
||||
- `./output/` — скачанные файлы.
|
||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
|
||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
|
||||
- `./state/manga.log` — логи.
|
||||
|
||||
При следующем запуске `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
|
||||
```
|
||||
|
||||
Откройте **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)
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `AUTH_LOGIN` | — | Логин системного администратора (создаётся при первом старте) |
|
||||
| `AUTH_PASSWORD` | — | Пароль системного администратора |
|
||||
| `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 отображает прогресс чтения серии.
|
||||
|
||||
---
|
||||
|
||||
## Редактирование метаданных
|
||||
|
||||
Через веб-интерфейс можно изменить название серии, не перекачивая файлы:
|
||||
|
||||
1. Кликните на строку манги → откроется окно деталей.
|
||||
2. Нажмите **✏️ Редактировать название**.
|
||||
3. Измените «Название (ru)» и/или «Полное название».
|
||||
4. Нажмите **Сохранить** — метаданные обновятся автоматически во всех скачанных файлах.
|
||||
|
||||
> **Важно:** папка на диске при этом **не переименовывается**. Чтобы переименовать папку — используйте отдельную функцию ниже.
|
||||
|
||||
---
|
||||
|
||||
## Переименование папки
|
||||
|
||||
Через веб-интерфейс можно изменить имя папки, в которую сохраняются файлы манги:
|
||||
|
||||
1. Кликните на строку манги → откроется окно деталей.
|
||||
2. Нажмите **📁 Переименовать папку**.
|
||||
3. Введите новое имя (спецсимволы удалятся автоматически, пробелы заменятся на `_`).
|
||||
4. Нажмите **Переименовать**.
|
||||
|
||||
После переименования:
|
||||
- Физическая папка на диске будет переименована.
|
||||
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||
|
||||
@@ -8,7 +8,14 @@ services:
|
||||
- ./state:/app/state
|
||||
environment:
|
||||
- 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:
|
||||
- "8000:8000"
|
||||
shm_size: "2gb"
|
||||
@@ -16,3 +23,11 @@ services:
|
||||
# Веб-интерфейс: http://localhost:8000
|
||||
# CLI-команды:
|
||||
# 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
|
||||
websockets==12.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()
|
||||
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):
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
111
src/cli.py
111
src/cli.py
@@ -16,9 +16,11 @@ from loguru import logger
|
||||
from tqdm import tqdm
|
||||
|
||||
from .browser import BrowserManager
|
||||
from .scraper import get_manga_info, get_chapter_images_and_download, Chapter
|
||||
from .exporter import export, ExportFormat
|
||||
from .sources import registry, get_source_for_url
|
||||
from .sources.base import Chapter
|
||||
from .exporter import export, ExportFormat, MangaMeta
|
||||
from .state import StateDB
|
||||
from .utils import safe_name, safe_chapter_name
|
||||
|
||||
OUTPUT_DIR = Path("/app/output")
|
||||
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):
|
||||
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:
|
||||
ctx, page = await bm.new_page()
|
||||
|
||||
# 1. Получаем список глав
|
||||
manga = await get_manga_info(page, url)
|
||||
manga = await source.get_manga_info(page, url)
|
||||
if not manga:
|
||||
logger.error("Не удалось получить информацию о манге")
|
||||
db.close()
|
||||
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)
|
||||
|
||||
# 2. Сохраняем все главы в БД
|
||||
for ch in manga.chapters:
|
||||
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
|
||||
|
||||
# 3. Фильтрация
|
||||
chapters = _filter_chapters(manga.chapters, chapters_filter)
|
||||
logger.info("Будет скачано глав: {}", len(chapters))
|
||||
|
||||
# 4. Форматы
|
||||
formats: list[ExportFormat] = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
||||
|
||||
# 5. Скачиваем каждую главу
|
||||
with tqdm(total=len(chapters), desc="Главы", unit="гл") as pbar:
|
||||
for ch in chapters:
|
||||
pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}")
|
||||
|
||||
# Проверяем статус (resume / force)
|
||||
if force:
|
||||
db.reset_chapter(ch.url)
|
||||
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
|
||||
|
||||
await _process_chapter(
|
||||
bm=bm, ctx=ctx, ch=ch,
|
||||
manga_url=url,
|
||||
source=source, ctx=ctx, ch=ch,
|
||||
manga=manga, manga_url=url,
|
||||
manga_dir=manga_dir, formats=formats,
|
||||
concurrency=concurrency, db=db, force=force,
|
||||
db=db, force=force,
|
||||
)
|
||||
pbar.update(1)
|
||||
|
||||
@@ -130,16 +137,14 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, force, concur
|
||||
db.close()
|
||||
|
||||
|
||||
async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path,
|
||||
formats: list, concurrency: int, db: StateDB, force: bool = False):
|
||||
# Новая страница для каждой главы (чистый контекст)
|
||||
async def _process_chapter(source, ctx, ch: Chapter, manga, manga_url: str,
|
||||
manga_dir: Path, formats: list, db: StateDB, force: bool = False):
|
||||
ch_page = await ctx.new_page()
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
# Открываем главу и скачиваем изображения за один проход
|
||||
image_paths = await get_chapter_images_and_download(
|
||||
image_paths = await source.get_chapter_images_and_download(
|
||||
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)
|
||||
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:
|
||||
out_file = manga_dir / f"{ch_name}.{fmt}"
|
||||
# При --force удаляем старый файл перед перезаписью
|
||||
if force and out_file.exists():
|
||||
out_file.unlink()
|
||||
logger.debug("Удалён старый файл: {}", out_file.name)
|
||||
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))
|
||||
except Exception as e:
|
||||
logger.error("Ошибка экспорта {}: {}", fmt, e)
|
||||
@@ -180,15 +196,28 @@ def analyze(ctx, url):
|
||||
|
||||
|
||||
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:
|
||||
_, page = await bm.new_page()
|
||||
manga = await get_manga_info(page, url)
|
||||
manga = await source.get_manga_info(page, url)
|
||||
|
||||
if not manga:
|
||||
click.echo("❌ Не удалось получить информацию")
|
||||
db.close()
|
||||
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"📖 Глав: {len(manga.chapters)}\n")
|
||||
|
||||
@@ -198,64 +227,34 @@ async def _analyze(url: str):
|
||||
if len(manga.chapters) > 20:
|
||||
click.echo(f" ... и ещё {len(manga.chapters) - 20} глав")
|
||||
|
||||
# Проверяем одну главу
|
||||
if manga.chapters:
|
||||
first = manga.chapters[-1]
|
||||
click.echo(f"\n🔍 Проверяем первую главу: {first.url}")
|
||||
import tempfile
|
||||
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
|
||||
)
|
||||
click.echo(f" Скачано изображений: {len(paths)}")
|
||||
for p in paths[:3]:
|
||||
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]:
|
||||
if not filter_str:
|
||||
return chapters
|
||||
|
||||
# "1-10" → диапазон
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$", filter_str)
|
||||
if m:
|
||||
lo, hi = float(m.group(1)), float(m.group(2))
|
||||
return [c for c in chapters if lo <= c.number <= hi]
|
||||
|
||||
# "1,3,7" → список
|
||||
nums = {float(x.strip()) for x in filter_str.split(",")}
|
||||
return [c for c in chapters if c.number in nums]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class MangaMeta:
|
||||
language: str = "ru"
|
||||
summary: str = "" # Описание/синопсис серии
|
||||
genre: str = "" # Жанры через запятую (для ComicInfo Genre)
|
||||
tags: str = "" # Теги через запятую (для ComicInfo Tags)
|
||||
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
|
||||
|
||||
|
||||
@@ -89,6 +90,7 @@ def _make_comic_info(meta: MangaMeta) -> str:
|
||||
add("Count", meta.chapters_total)
|
||||
|
||||
add("Genre", meta.genre)
|
||||
add("Tags", meta.tags)
|
||||
add("LanguageISO", meta.language)
|
||||
|
||||
# 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):
|
||||
from PIL import Image
|
||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||
try:
|
||||
if pil_images:
|
||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||
finally:
|
||||
for img in pil_images:
|
||||
img.close()
|
||||
|
||||
|
||||
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
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .sources.base import Chapter, MangaInfo # noqa: F401 — реэкспорт для импортёров
|
||||
from .sources.readmanga import ReadmangaSource
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import Page
|
||||
|
||||
from .browser import BrowserManager
|
||||
_instance = ReadmangaSource()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Модели данных
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
title: str
|
||||
url: str
|
||||
number: float = 0.0
|
||||
volume: int = 0
|
||||
async def get_manga_info(page, url):
|
||||
return await _instance.get_manga_info(page, url)
|
||||
|
||||
|
||||
@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) # Жанры
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Страница манги — список глав
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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 get_chapter_images_and_download(page, chapter_url, dest_dir,
|
||||
manga_url=None, on_page=None):
|
||||
return await _instance.get_chapter_images_and_download(
|
||||
page, chapter_url, dest_dir, manga_url=manga_url, on_page=on_page
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, db_path: Path = DB_PATH):
|
||||
@@ -35,7 +72,11 @@ class StateDB:
|
||||
added_at TEXT,
|
||||
updated_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("""
|
||||
@@ -68,6 +109,41 @@ class StateDB:
|
||||
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
|
||||
migrations = [
|
||||
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
||||
@@ -76,9 +152,17 @@ class StateDB:
|
||||
("mangas", "title_full", "TEXT"),
|
||||
("mangas", "pub_status", "TEXT DEFAULT 'unknown'"),
|
||||
("mangas", "auto_update", "INTEGER DEFAULT 0"),
|
||||
("mangas", "last_checked_at", "TEXT"),
|
||||
("mangas", "last_checked_at","TEXT"),
|
||||
("mangas", "started_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:
|
||||
try:
|
||||
@@ -87,27 +171,244 @@ class StateDB:
|
||||
pass
|
||||
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 ────────────────────────────────────
|
||||
|
||||
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 если новая."""
|
||||
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
||||
if cur.fetchone():
|
||||
return False
|
||||
self.conn.execute("""
|
||||
INSERT INTO mangas (url, format, status, added_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?)
|
||||
""", (url, fmt, _now(), _now()))
|
||||
INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?, ?)
|
||||
""", (url, fmt, source_id, added_by, _now(), _now()))
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def update_manga_info(self, url: str, title: str, chapters_total: int,
|
||||
title_ru: str = "", title_full: str = "",
|
||||
pub_status: str = "unknown"):
|
||||
pub_status: str = "unknown",
|
||||
description: str = "", tags: str = "",
|
||||
cover_url: str = ""):
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET title=?, title_ru=?, title_full=?, pub_status=?,
|
||||
chapters_total=?, updated_at=? WHERE url=?
|
||||
""", (title, title_ru, title_full, pub_status, chapters_total, _now(), url))
|
||||
chapters_total=?, updated_at=?,
|
||||
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()
|
||||
|
||||
def set_auto_update(self, url: str, enabled: bool):
|
||||
@@ -128,6 +429,26 @@ class StateDB:
|
||||
""", (status, _now(), url))
|
||||
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:
|
||||
"""Записывает время начала загрузки. Возвращает timestamp."""
|
||||
ts = _now()
|
||||
@@ -158,17 +479,21 @@ class StateDB:
|
||||
self.conn.commit()
|
||||
return count
|
||||
|
||||
def increment_manga_chapters_done(self, url: str):
|
||||
# Оставлен для совместимости, но не используется в воркере
|
||||
pass
|
||||
|
||||
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()
|
||||
return dict(row) if row else None
|
||||
|
||||
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()]
|
||||
|
||||
def get_manga_format(self, url: str) -> str:
|
||||
@@ -176,6 +501,57 @@ class StateDB:
|
||||
row = cur.fetchone()
|
||||
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 ──────────────────────────────────
|
||||
|
||||
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
||||
@@ -199,6 +575,8 @@ class StateDB:
|
||||
self.conn.commit()
|
||||
|
||||
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}"
|
||||
self.conn.execute(f"""
|
||||
UPDATE chapters SET status='done', {col}=?, updated_at=?
|
||||
@@ -284,10 +662,91 @@ class StateDB:
|
||||
""")
|
||||
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):
|
||||
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 os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
@@ -11,9 +10,12 @@ from typing import Callable, Optional
|
||||
from loguru import logger
|
||||
|
||||
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 .state import StateDB
|
||||
from .utils import safe_name, safe_chapter_name
|
||||
|
||||
OUTPUT_DIR = Path("/app/output")
|
||||
|
||||
@@ -21,15 +23,6 @@ OUTPUT_DIR = Path("/app/output")
|
||||
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(
|
||||
url: str,
|
||||
fmt: str = "cbz",
|
||||
@@ -61,18 +54,52 @@ async def download_manga(
|
||||
started_ts = await db_call(db.mark_started, url)
|
||||
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:
|
||||
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 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:
|
||||
await info_page.close()
|
||||
await db_call(db.update_manga_status, url, "failed")
|
||||
await emit({"type": "manga_failed", "url": url,
|
||||
"error": "Не удалось получить информацию о манге"})
|
||||
return
|
||||
|
||||
import json as _json_mod
|
||||
await db_call(
|
||||
db.update_manga_info,
|
||||
url,
|
||||
@@ -81,6 +108,9 @@ async def download_manga(
|
||||
title_ru=manga.title_ru,
|
||||
title_full=manga.title_full,
|
||||
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({
|
||||
"type": "manga_info",
|
||||
@@ -92,10 +122,21 @@ async def download_manga(
|
||||
"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.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:
|
||||
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||
|
||||
@@ -173,22 +214,23 @@ async def download_manga(
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pages_done_count = [0]
|
||||
pages_done = 0
|
||||
|
||||
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,
|
||||
ch.url, pages_total, pages_done_count[0])
|
||||
ch.url, pages_total, pages_done)
|
||||
await emit({
|
||||
"type": "page_done",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"page_idx": page_idx,
|
||||
"pages_done": pages_done_count[0],
|
||||
"pages_done": pages_done,
|
||||
"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,
|
||||
dest_dir=tmp_path,
|
||||
manga_url=url,
|
||||
@@ -206,7 +248,7 @@ async def download_manga(
|
||||
"chapter_url": ch.url})
|
||||
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 "",
|
||||
@@ -218,6 +260,7 @@ async def download_manga(
|
||||
source_url=url,
|
||||
summary=manga.description,
|
||||
genre=", ".join(manga.genres) if manga.genres else "",
|
||||
tags=", ".join(manga.tags) if manga.tags else "",
|
||||
)
|
||||
for f in formats:
|
||||
out_file = manga_dir / f"{ch_name}.{f}"
|
||||
@@ -256,6 +299,8 @@ async def download_manga(
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
except AuthRequiredError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
||||
@@ -271,14 +316,70 @@ async def download_manga(
|
||||
tasks = [process_chapter(ch) for ch in to_download]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Логируем неожиданные исключения из gather
|
||||
# Логируем неожиданные исключения из gather; обнаруживаем auth ошибки
|
||||
auth_slug = None
|
||||
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(
|
||||
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
||||
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)
|
||||
await db_call(db.update_manga_status, url, "done")
|
||||
finished_ts = await db_call(db.mark_finished, url)
|
||||
@@ -293,6 +394,8 @@ async def download_manga(
|
||||
await ctx.close()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Прокидываем выше — обработка статуса уже сделана API-эндпоинтом
|
||||
# (или воркером в _queue_worker_loop).
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Manga worker error {}: {}", url, e)
|
||||
@@ -303,6 +406,244 @@ async def download_manga(
|
||||
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(
|
||||
url: str,
|
||||
on_event: Optional[Callable] = None,
|
||||
@@ -319,35 +660,71 @@ async def check_for_updates(
|
||||
pass
|
||||
|
||||
db = StateDB()
|
||||
db_lock = asyncio.Lock()
|
||||
|
||||
async def db_call(fn, *args, **kwargs):
|
||||
async with db_lock:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
try:
|
||||
db.set_last_checked(url)
|
||||
db.add_history(manga_url=url, event_type="check_started")
|
||||
await db_call(db.set_last_checked, url)
|
||||
await db_call(db.add_history, manga_url=url, event_type="check_started")
|
||||
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:
|
||||
_, page = await bm.new_page()
|
||||
manga = await get_manga_info(page, url)
|
||||
await page.close()
|
||||
manga = await source.get_manga_info(page, url)
|
||||
if not manga:
|
||||
await page.close()
|
||||
return []
|
||||
|
||||
# Обновляем pub_status и количество глав
|
||||
db.update_manga_info(
|
||||
import json as _json_mod
|
||||
# Обновляем pub_status, количество глав и мета-поля
|
||||
await db_call(
|
||||
db.update_manga_info,
|
||||
url,
|
||||
title=manga.title_ru or manga.title,
|
||||
chapters_total=len(manga.chapters),
|
||||
title_ru=manga.title_ru,
|
||||
title_full=manga.title_full,
|
||||
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]
|
||||
|
||||
for ch in new_chapters:
|
||||
db.upsert_chapter(url, ch.url, ch.title, ch.number, ch.volume)
|
||||
db.add_history(
|
||||
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||
await db_call(
|
||||
db.add_history,
|
||||
manga_url=url,
|
||||
event_type="new_chapter_found",
|
||||
chapter_url=ch.url,
|
||||
@@ -363,7 +740,8 @@ async def check_for_updates(
|
||||
"chapter_number": ch.number,
|
||||
})
|
||||
|
||||
db.add_history(
|
||||
await db_call(
|
||||
db.add_history,
|
||||
manga_url=url,
|
||||
event_type="check_done",
|
||||
details=f"Найдено новых: {len(new_chapters)}",
|
||||
|
||||
Reference in New Issue
Block a user