Base app
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
.idea
|
||||
/output/
|
||||
/state/
|
||||
|
||||
658
ARCHITECTURE.md
Normal file
658
ARCHITECTURE.md
Normal file
@@ -0,0 +1,658 @@
|
||||
# Manga Downloader — Архитектура и устройство проекта
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Общее описание](#1-общее-описание)
|
||||
2. [Структура файлов](#2-структура-файлов)
|
||||
3. [Стек технологий](#3-стек-технологий)
|
||||
4. [Схема архитектуры](#4-схема-архитектуры)
|
||||
5. [Модули бэкенда](#5-модули-бэкенда)
|
||||
- [browser.py](#browserpy)
|
||||
- [scraper.py](#scraperpy)
|
||||
- [exporter.py](#exporterpy)
|
||||
- [state.py](#statepy)
|
||||
- [worker.py](#workerpy)
|
||||
- [api.py](#apipy)
|
||||
- [cli.py](#clipy)
|
||||
6. [База данных](#6-база-данных)
|
||||
7. [REST API](#7-rest-api)
|
||||
8. [WebSocket протокол](#8-websocket-протокол)
|
||||
9. [Фронтенд](#9-фронтенд)
|
||||
10. [Жизненный цикл загрузки манги](#10-жизненный-цикл-загрузки-манги)
|
||||
11. [Параллельная загрузка](#11-параллельная-загрузка)
|
||||
12. [Конфигурация](#12-конфигурация)
|
||||
13. [Docker-инфраструктура](#13-docker-инфраструктура)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общее описание
|
||||
|
||||
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
|
||||
|
||||
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket.
|
||||
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура файлов
|
||||
|
||||
```
|
||||
manga/
|
||||
├── src/ # Весь бэкенд-код (Python-пакет)
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # FastAPI-приложение, REST + WebSocket
|
||||
│ ├── browser.py # Обёртка над Playwright/Chromium
|
||||
│ ├── cli.py # CLI-команды (click)
|
||||
│ ├── downloader.py # (legacy, не используется в web-режиме)
|
||||
│ ├── exporter.py # Экспорт CBZ / PDF / EPUB
|
||||
│ ├── scraper.py # Парсер страниц readmanga.ru
|
||||
│ ├── state.py # SQLite ORM (StateDB)
|
||||
│ └── worker.py # Асинхронный воркер загрузки
|
||||
│
|
||||
├── frontend/
|
||||
│ └── index.html # SPA — весь фронтенд в одном файле
|
||||
│
|
||||
├── output/ # Смонтированная папка с CBZ/PDF/EPUB
|
||||
│ └── <Название манги>/
|
||||
│ ├── v01_ch0001.0.cbz
|
||||
│ └── ...
|
||||
│
|
||||
├── state/ # Смонтированная папка с состоянием
|
||||
│ ├── progress.db # SQLite база данных
|
||||
│ └── manga.log # Логи (ротация по 10 МБ)
|
||||
│
|
||||
├── Dockerfile # Сборка образа
|
||||
├── docker-compose.yml # Конфигурация сервисов
|
||||
├── requirements.txt # Python-зависимости
|
||||
├── debug_site.py # Утилита отладки (скриншот страницы)
|
||||
├── debug_cdn.py # Утилита отладки CDN-запросов
|
||||
├── analyze_speed.py # Анализ скорости загрузки
|
||||
├── README.md # Краткое руководство пользователя
|
||||
└── ARCHITECTURE.md # Этот файл
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Стек технологий
|
||||
|
||||
| Компонент | Библиотека | Назначение |
|
||||
|-----------|------------|------------|
|
||||
| Браузер | `playwright==1.44.0` (Chromium) | Открытие защищённых JS-страниц |
|
||||
| Web-фреймворк | `fastapi==0.111.0` + `uvicorn` | REST API и WebSocket |
|
||||
| БД | SQLite (stdlib `sqlite3`) | Хранение состояния |
|
||||
| CLI | `click==8.1.7` | Консольные команды |
|
||||
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
||||
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
|
||||
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
|
||||
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
|
||||
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
|
||||
|
||||
---
|
||||
|
||||
## 4. Схема архитектуры
|
||||
|
||||
```
|
||||
Браузер пользователя
|
||||
│
|
||||
│ HTTP / WebSocket (:8000)
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ FastAPI (api.py) │
|
||||
│ │
|
||||
│ REST endpoints WebSocket /ws │
|
||||
│ │ │ │
|
||||
│ asyncio.Queue ws_manager │
|
||||
│ │ (broadcast) │
|
||||
│ ▼ ▲ │
|
||||
│ queue_worker() │ │
|
||||
│ │ events │
|
||||
│ ▼ │ │
|
||||
│ download_manga() ────┘ │ ◄── worker.py
|
||||
│ │ │
|
||||
└───────┼─────────────────────────┘
|
||||
│
|
||||
├─► BrowserManager (browser.py)
|
||||
│ └─► Playwright Chromium
|
||||
│ │
|
||||
│ ┌───────────┴──────────┐
|
||||
│ ▼ ▼
|
||||
│ get_manga_info() get_chapter_images_and_download()
|
||||
│ (scraper.py) (scraper.py)
|
||||
│
|
||||
├─► export() (exporter.py) → CBZ / PDF / EPUB
|
||||
│
|
||||
└─► StateDB (state.py) → progress.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Модули бэкенда
|
||||
|
||||
### browser.py
|
||||
|
||||
**Отвечает за:** запуск и управление Playwright Chromium.
|
||||
|
||||
**Ключевые детали:**
|
||||
|
||||
- `BrowserManager` — контекстный менеджер (`async with BrowserManager() as bm`), запускает/останавливает браузер.
|
||||
- `new_page()` — создаёт новый `BrowserContext` + `Page`. Каждый контекст независим (отдельные куки, заголовки).
|
||||
- Браузер запускается с антидетект-настройками:
|
||||
- `--disable-blink-features=AutomationControlled`
|
||||
- JavaScript-патч `STEALTH_JS`: скрывает `navigator.webdriver`, подставляет плагины и языки.
|
||||
- Реалистичный `User-Agent` (Chrome 124 Linux).
|
||||
- Заголовки `Accept-Language: ru-RU`, `Referer: https://3.readmanga.ru/` — сервер возвращает 404 без Referer.
|
||||
- `shm_size: 2gb` в Docker — Chromium требует shared memory для рендеринга.
|
||||
|
||||
---
|
||||
|
||||
### scraper.py
|
||||
|
||||
**Отвечает за:** парсинг страниц манги и скачивание изображений глав.
|
||||
|
||||
#### Модели данных
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Chapter:
|
||||
title: str # "Том 1, Глава 5"
|
||||
url: str # полный URL главы
|
||||
number: float # 5.0 (из data-num / 10)
|
||||
volume: int # 1
|
||||
|
||||
@dataclass
|
||||
class MangaInfo:
|
||||
title: str # русский тайтл (для имени папки)
|
||||
url: str
|
||||
chapters: list[Chapter]
|
||||
pub_status: str # "completed" / "ongoing" / "unknown"
|
||||
title_ru: str # чистый русский тайтл
|
||||
title_full: str # полный тайтл со страницы
|
||||
```
|
||||
|
||||
#### `get_manga_info(page, url)`
|
||||
|
||||
1. Открывает страницу манги через `_navigate()` (3 попытки, задержка 3×N сек).
|
||||
2. Извлекает тайтл: сначала из DOM (`.names .name`), fallback — парсинг `<title>` (убирает «Манга», «онлайн», английские названия в скобках).
|
||||
3. Извлекает `pub_status` по тексту `.elem_status .value` (ищет «завершён» / «продолжается»).
|
||||
4. Раскрывает полный список глав кнопкой «Все главы» (`_expand_chapters`).
|
||||
5. Парсит главы из `#chapters-list tr.item-row` → `td[data-num]` (num/10 = номер главы, `data-vol` = том). Fallback: `<a href*="/vol">`.
|
||||
|
||||
#### `get_chapter_images_and_download(page, chapter_url, dest_dir, ...)`
|
||||
|
||||
Самая сложная функция — обходит CDN-защиту изображений:
|
||||
|
||||
1. Открывает главу с параметром `?mtr=1` (флаг согласия 18+).
|
||||
2. Устанавливает `page.route("**/*", route_handler)` — перехватывает все сетевые запросы.
|
||||
3. Ждёт `readerInit` в JS-коде страницы — там содержится массив URL всех изображений.
|
||||
4. Извлекает URL из `readerInit` через JS-eval (`_extract_images_from_js`). Fallback: ищет `img.manga-page` в DOM.
|
||||
5. Перехватывает img-запросы через `route.fetch()` — запрос идёт браузерным стеком (правильные `Sec-Fetch-*`, cookies DDoS-Guard). Сохраняет байты в памяти.
|
||||
6. Листает читалку клавишей `ArrowRight` — читалка подгружает страницы лениво.
|
||||
7. Матчит перехваченные URL с индексами из `readerInit` (основной путь + fallback по имени файла).
|
||||
8. Сохраняет в `dest_dir/0000.jpg`, `0001.png`, ...
|
||||
|
||||
**CDN-фильтр:** принимает только запросы к `one-way.work`, `staticfa.`, `cdnmanga`, `reimg`. Отклоняет статику сайта (`resrmr.`, `/static/`).
|
||||
|
||||
---
|
||||
|
||||
### exporter.py
|
||||
|
||||
**Отвечает за:** сборку файлов из набора изображений с встраиванием метаданных.
|
||||
|
||||
#### `MangaMeta` — датакласс метаданных
|
||||
|
||||
Передаётся в `export()` из воркера. Поля:
|
||||
|
||||
| Поле | Описание |
|
||||
|------|---------|
|
||||
| `series` | Название серии (title_ru) |
|
||||
| `series_full` | Полное название со страницы (title_full) |
|
||||
| `chapter_title` | Название главы |
|
||||
| `number` | Номер главы (float) |
|
||||
| `volume` | Том |
|
||||
| `chapters_total` | Всего глав (для completed — записывается в Count) |
|
||||
| `pub_status` | `completed` / `ongoing` / `unknown` |
|
||||
| `source_url` | URL источника |
|
||||
| `language` | `ru` |
|
||||
|
||||
#### Форматы и метаданные
|
||||
|
||||
| Формат | Реализация | Метаданные |
|
||||
|--------|------------|-----------|
|
||||
| **CBZ** | `zipfile` + `ComicInfo.xml` первым файлом | Anansi v2 schema: `Series`, `Number`, `Volume`, `Count` (если completed), `LanguageISO`, `Manga=YesAndRightToLeft`, `Web` |
|
||||
| **PDF** | `img2pdf` (fallback: Pillow) + `pypdf` post-processing | PDF `/Info` (`/Title`, `/Subject`) + XMP Dublin Core (`dc:title`, `dc:description`, `dc:language`, `dc:source`) |
|
||||
| **EPUB** | `ebooklib` | Dublin Core, `calibre:series` + `calibre:series_index`, EPUB3 `belongs-to-collection` + `group-position` |
|
||||
|
||||
`_patch_pdf_meta()` открывает готовый PDF через `pypdf`, добавляет метаданные и перезаписывает файл. Если `pypdf` не установлен — молча пропускает (graceful degradation).
|
||||
|
||||
---
|
||||
|
||||
### state.py
|
||||
|
||||
**Отвечает за:** всё взаимодействие с SQLite БД.
|
||||
|
||||
#### Класс `StateDB`
|
||||
|
||||
Подключение: `sqlite3.connect(path, check_same_thread=False)`.
|
||||
|
||||
#### Таблицы
|
||||
|
||||
**`mangas`** — одна строка на мангу:
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|---------|
|
||||
| `url` | TEXT UNIQUE | URL страницы манги |
|
||||
| `title` / `title_ru` / `title_full` | TEXT | Варианты названия |
|
||||
| `pub_status` | TEXT | `completed` / `ongoing` / `unknown` |
|
||||
| `auto_update` | INTEGER | 0/1 — включено ли авто-обновление |
|
||||
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
|
||||
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
|
||||
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
||||
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
|
||||
| `last_checked_at` | TEXT | Время последней проверки новых глав |
|
||||
|
||||
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
|
||||
|
||||
**`chapters`** — одна строка на главу:
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|---------|
|
||||
| `chapter_url` | TEXT UNIQUE | URL главы |
|
||||
| `manga_url` | TEXT | FK → mangas.url |
|
||||
| `number` | REAL | Номер главы (5.0) |
|
||||
| `volume` | INTEGER | Том |
|
||||
| `status` | TEXT | `pending` / `done` / `failed` |
|
||||
| `pages_total` / `pages_done` | INTEGER | Прогресс страниц |
|
||||
| `output_cbz` / `output_pdf` / `output_epub` | TEXT | Путь к файлу |
|
||||
|
||||
**`history`** — лог событий:
|
||||
|
||||
| `event_type` | Описание |
|
||||
|---|---|
|
||||
| `downloaded` | Глава успешно скачана |
|
||||
| `auto_downloaded` | Скачана в режиме авто-обновления |
|
||||
| `new_chapter_found` | Найдена новая глава при проверке |
|
||||
| `check_started` / `check_done` | Начало/конец проверки обновлений |
|
||||
|
||||
#### Ключевые методы
|
||||
|
||||
- `add_manga(url, fmt)` → `bool` — добавляет, возвращает `False` если уже есть.
|
||||
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
|
||||
- `chapter_status(chapter_url)` → `str | None`.
|
||||
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число.
|
||||
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
|
||||
|
||||
---
|
||||
|
||||
### worker.py
|
||||
|
||||
**Отвечает за:** асинхронное скачивание манги с параллельными главами.
|
||||
|
||||
#### `download_manga(url, fmt, resume, is_update, on_event, chapter_concurrency)`
|
||||
|
||||
Полный цикл скачивания одной манги:
|
||||
|
||||
```
|
||||
1. update_manga_status → "downloading"
|
||||
2. BrowserManager.__aenter__
|
||||
3. get_manga_info() → MangaInfo
|
||||
4. update_manga_info() в БД
|
||||
5. upsert_chapter() для каждой главы
|
||||
6. Делим главы:
|
||||
├── 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"
|
||||
```
|
||||
|
||||
#### `process_chapter(ch)` (внутренняя корутина)
|
||||
|
||||
```
|
||||
async with sem: # ограничение параллельности
|
||||
1. Повторная проверка chapter_status (race condition guard)
|
||||
2. ctx.new_page() — новая вкладка в общем контексте
|
||||
3. get_chapter_images_and_download() → list[Path]
|
||||
4. export() для каждого формата
|
||||
5. mark_done() / mark_failed()
|
||||
6. add_history()
|
||||
7. chapter_done event
|
||||
finally: ch_page.close()
|
||||
```
|
||||
|
||||
#### Потокобезопасность
|
||||
|
||||
`db_lock = asyncio.Lock()` — все обращения к SQLite через `await db_call(fn, *args)`. Это необходимо, так как несколько корутин работают одновременно, а `sqlite3` не является asyncio-safe.
|
||||
|
||||
`counter_lock = asyncio.Lock()` — атомарный инкремент счётчика `chapters_done` для корректных данных в событиях прогресса.
|
||||
|
||||
#### `check_for_updates(url, on_event)`
|
||||
|
||||
Проверяет новые главы: сравнивает список глав из scraper с известными в БД. Возвращает список новых `chapter_url`.
|
||||
|
||||
---
|
||||
|
||||
### api.py
|
||||
|
||||
**Отвечает за:** FastAPI-приложение — HTTP-сервер, очередь загрузок, планировщик обновлений.
|
||||
|
||||
#### Глобальное состояние
|
||||
|
||||
```python
|
||||
download_queue: asyncio.Queue # очередь job'ов {url, fmt, is_update}
|
||||
active_tasks: dict[str, asyncio.Task] # url → текущая Task загрузки
|
||||
ws_manager: ConnectionManager # set активных WebSocket-соединений
|
||||
```
|
||||
|
||||
#### Жизненный цикл при старте (`startup_event`)
|
||||
|
||||
1. Запускает `queue_worker()` как фоновую Task.
|
||||
2. Запускает `update_scheduler()` как фоновую Task.
|
||||
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь).
|
||||
|
||||
#### `queue_worker()`
|
||||
|
||||
Последовательно извлекает задачи из `download_queue`. На каждую создаёт `asyncio.Task` через `download_manga()`, сохраняет в `active_tasks`. Обрабатывает `CancelledError` (stop/prioritize).
|
||||
|
||||
#### `update_scheduler()`
|
||||
|
||||
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч):
|
||||
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`.
|
||||
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`.
|
||||
|
||||
#### `_enrich_manga(m, db)`
|
||||
|
||||
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
|
||||
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не стальная колонка).
|
||||
- `size_bytes` / `size_human` — размер папки на диске.
|
||||
- `is_active` — есть ли Task в `active_tasks`.
|
||||
- `errors_count` — сумма failed и partial глав.
|
||||
|
||||
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
|
||||
|
||||
---
|
||||
|
||||
### cli.py
|
||||
|
||||
**Отвечает за:** консольные команды для запуска без веб-интерфейса.
|
||||
|
||||
#### Команды
|
||||
|
||||
**`download <URL>`**
|
||||
|
||||
```
|
||||
--format / -f cbz|pdf|epub|all (default: cbz)
|
||||
--chapters / -c диапазон: "1-10", "5", "1,3,7"
|
||||
--output / -o папка вывода
|
||||
--resume пропускать скачанные (default: True)
|
||||
--force / -F игнорировать БД, скачать заново
|
||||
--concurrency параллельных загрузок (default: 4)
|
||||
--verbose / -v DEBUG-вывод
|
||||
```
|
||||
|
||||
**`analyze <URL>`**
|
||||
|
||||
Открывает страницу, выводит список всех глав и метаданные без скачивания.
|
||||
|
||||
CLI использует те же `BrowserManager`, `scraper`, `exporter`, `StateDB`, что и web-режим, но запускает их напрямую через `asyncio.run()`.
|
||||
|
||||
---
|
||||
|
||||
## 6. База данных
|
||||
|
||||
**Файл:** `/app/state/progress.db` (монтируется из `./state/progress.db` на хосте).
|
||||
|
||||
**Открытие:** `sqlite3.connect(path, check_same_thread=False)` — один объект `StateDB` на запрос/worker, не shared между потоками.
|
||||
|
||||
**Миграции:** при каждом запуске `StateDB._init()` выполняет `ALTER TABLE ... ADD COLUMN` в блоке `try/except` — безопасно добавляет новые колонки в старые БД.
|
||||
|
||||
**Важно:** колонка `mangas.chapters_done` является устаревшим денормализованным счётчиком. В API и WebSocket snapshot всегда используется динамический подсчёт из таблицы `chapters`. Это важно, чтобы не показывать некорректные числа (> chapters_total) после перезапусков.
|
||||
|
||||
---
|
||||
|
||||
## 7. REST API
|
||||
|
||||
Базовый URL: `http://localhost:8000`
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|---------|
|
||||
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
|
||||
| `GET` | `/api/mangas/detail?url=` | Детали: главы, файлы, статистика ошибок |
|
||||
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
|
||||
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
|
||||
| `POST` | `/api/mangas/resume?url=` | Возобновить |
|
||||
| `POST` | `/api/mangas/prioritize?url=` | Переместить в начало очереди (вытесняет текущую) |
|
||||
| `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=` | Удалить мангу из БД |
|
||||
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
|
||||
| `GET` | `/api/history?limit=&manga_url=` | История событий |
|
||||
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
|
||||
| `WS` | `/ws` | WebSocket для realtime-обновлений |
|
||||
|
||||
---
|
||||
|
||||
## 8. WebSocket протокол
|
||||
|
||||
### Сервер → Клиент (события)
|
||||
|
||||
| `type` | Данные | Когда |
|
||||
|--------|--------|-------|
|
||||
| `snapshot` | `{mangas: [...]}` | При подключении — полный список с реальными счётчиками |
|
||||
| `manga_queued` | `{url, format}` | Добавлена в очередь |
|
||||
| `manga_start` | `{url}` | Начало загрузки |
|
||||
| `manga_info` | `{url, title, chapters_total, pub_status, ...}` | Получены метаданные |
|
||||
| `manga_done` | `{url, chapters_done, chapters_total}` | Загрузка завершена |
|
||||
| `manga_failed` | `{url, error}` | Ошибка |
|
||||
| `manga_stopped` | `{url}` | Остановлена |
|
||||
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
||||
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
||||
| `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}` | Глава пропущена (уже скачана) |
|
||||
| `chapter_failed` | `{url, chapter_url, error?}` | Ошибка главы |
|
||||
| `page_done` | `{url, chapter_url, pages_done, pages_total}` | Страница скачана |
|
||||
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
||||
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
||||
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
|
||||
|
||||
### Клиент → Сервер
|
||||
|
||||
| Сообщение | Действие |
|
||||
|-----------|---------|
|
||||
| `"ping"` | Сервер отвечает `{"type": "pong"}` — keepalive |
|
||||
|
||||
---
|
||||
|
||||
## 9. Фронтенд
|
||||
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк).
|
||||
|
||||
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
||||
|
||||
### Архитектура состояния
|
||||
|
||||
```javascript
|
||||
const state = {
|
||||
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
|
||||
chapters: {}, // url → массив глав (загружается по запросу в модалке)
|
||||
};
|
||||
```
|
||||
|
||||
### Поток данных
|
||||
|
||||
```
|
||||
DOMContentLoaded
|
||||
│
|
||||
├─ loadStats() ──────────────────────► GET /api/stats
|
||||
│
|
||||
├─ connectWS() ──────────────────────► WS /ws
|
||||
│ │
|
||||
│ └─ snapshot event ──────────► state.mangas = enriched list
|
||||
│ + live events ──────────► state.mangas[url].* обновляется
|
||||
│
|
||||
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
|
||||
(перезаписывает snapshot если пришёл раньше)
|
||||
```
|
||||
|
||||
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
||||
|
||||
### Вкладки
|
||||
|
||||
- **Манга** — список всех манг, добавление, управление.
|
||||
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
|
||||
- **История** — все события из таблицы `history`.
|
||||
|
||||
### Модальное окно детали
|
||||
|
||||
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок.
|
||||
|
||||
---
|
||||
|
||||
## 10. Жизненный цикл загрузки манги
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 12. Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||
|
||||
### Пути (hardcoded в коде)
|
||||
|
||||
| Константа | Путь |
|
||||
|-----------|------|
|
||||
| `OUTPUT_DIR` | `/app/output` |
|
||||
| `FRONTEND_DIR` | `/app/frontend` |
|
||||
| `DB_PATH` | `/app/state/progress.db` |
|
||||
| Лог | `/app/state/manga.log` (ротация 10 МБ) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Docker-инфраструктура
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```
|
||||
FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
|
||||
└── Ubuntu 22.04 + Python + все системные зависимости для Chromium
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
|
||||
CMD uvicorn src.api:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./output:/app/output # CBZ/PDF/EPUB файлы
|
||||
- ./state:/app/state # БД и логи
|
||||
|
||||
ports:
|
||||
- "8000:8000" # Веб-интерфейс
|
||||
|
||||
shm_size: "2gb" # Chromium требует shared memory
|
||||
environment:
|
||||
- UPDATE_INTERVAL_HOURS=6
|
||||
|
||||
restart: unless-stopped # Автоперезапуск при падении
|
||||
```
|
||||
|
||||
### CLI-режим (через compose run)
|
||||
|
||||
```bash
|
||||
# Скачать мангу без веб-интерфейса
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format cbz
|
||||
|
||||
# Анализ
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli analyze https://3.readmanga.ru/magicheskaia_bitva
|
||||
```
|
||||
|
||||
### Хранение данных
|
||||
|
||||
После остановки контейнера все данные сохраняются на хосте:
|
||||
- `./output/` — скачанные файлы.
|
||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
|
||||
- `./state/manga.log` — логи.
|
||||
|
||||
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
||||
|
||||
@@ -10,14 +10,13 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
|
||||
COPY src/ ./src/
|
||||
COPY frontend/ ./frontend/
|
||||
COPY debug_site.py ./debug_site.py
|
||||
COPY debug_cdn.py ./debug_cdn.py
|
||||
|
||||
# Выходные данные и состояние монтируются снаружи
|
||||
VOLUME ["/app/output", "/app/state"]
|
||||
|
||||
ENTRYPOINT ["python", "-m", "src.cli"]
|
||||
CMD ["--help"]
|
||||
|
||||
|
||||
|
||||
# По умолчанию запускаем веб-сервер
|
||||
ENTRYPOINT []
|
||||
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
113
README.md
113
README.md
@@ -1,11 +1,21 @@
|
||||
# Manga Downloader
|
||||
|
||||
Загрузчик манги с readmanga.ru. Использует Playwright + Chromium для обхода JS-защиты сайта.
|
||||
Загрузчик манги с readmanga.ru и совместимых сайтов. Использует Playwright + Chromium для обхода JS-защиты (DDoS-Guard, антибот).
|
||||
|
||||
Работает в двух режимах:
|
||||
- **Веб-интерфейс** — браузерная панель управления с realtime-прогрессом через WebSocket
|
||||
- **CLI** — консольные команды через `docker compose run`
|
||||
|
||||
📖 Подробная архитектурная документация: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## Требования
|
||||
|
||||
- Docker + Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Собрать образ
|
||||
@@ -14,49 +24,55 @@
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### 2. Анализировать мангу (проверить доступность, список глав)
|
||||
### 2. Запустить веб-интерфейс
|
||||
|
||||
```bash
|
||||
docker compose run --rm manga analyze https://3.readmanga.ru/magicheskaia_bitva
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Скачать всю мангу
|
||||
Откройте **http://localhost:8000** — вставьте URL манги, выберите формат, нажмите «Добавить».
|
||||
|
||||
---
|
||||
|
||||
## CLI-команды
|
||||
|
||||
### Скачать мангу
|
||||
|
||||
```bash
|
||||
# CBZ (по умолчанию)
|
||||
docker compose run --rm manga download https://3.readmanga.ru/magicheskaia_bitva
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva
|
||||
|
||||
# PDF
|
||||
docker compose run --rm manga download https://3.readmanga.ru/magicheskaia_bitva --format pdf
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format pdf
|
||||
|
||||
# Все форматы сразу
|
||||
docker compose run --rm manga download https://3.readmanga.ru/magicheskaia_bitva --format all
|
||||
|
||||
# EPUB
|
||||
docker compose run --rm manga download https://3.readmanga.ru/magicheskaia_bitva --format epub
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format all
|
||||
```
|
||||
|
||||
### 4. Скачать определённые главы
|
||||
### Скачать определённые главы
|
||||
|
||||
```bash
|
||||
# Главы с 1 по 10
|
||||
docker compose run --rm manga download <URL> --chapters 1-10
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download <URL> --chapters 1-10
|
||||
|
||||
# Конкретные главы
|
||||
docker compose run --rm manga download <URL> --chapters 1,5,10
|
||||
|
||||
# Одна глава
|
||||
docker compose run --rm manga download <URL> --chapters 47
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download <URL> --chapters 1,5,10
|
||||
```
|
||||
|
||||
### 5. Продолжить прерванное скачивание
|
||||
|
||||
Скачивание автоматически продолжается с того места, где остановилось (флаг `--resume` включён по умолчанию).
|
||||
### Анализировать мангу (список глав без скачивания)
|
||||
|
||||
```bash
|
||||
docker compose run --rm manga download <URL> --resume
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli analyze https://3.readmanga.ru/magicheskaia_bitva
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Выходные файлы
|
||||
|
||||
Файлы сохраняются в `./output/<название манги>/`:
|
||||
@@ -64,23 +80,54 @@ docker compose run --rm manga download <URL> --resume
|
||||
```
|
||||
output/
|
||||
Магическая_битва/
|
||||
v01_ch001.0.cbz
|
||||
v01_ch002.0.cbz
|
||||
v01_ch0001.0.cbz
|
||||
v01_ch0002.0.cbz
|
||||
...
|
||||
```
|
||||
|
||||
## Прогресс
|
||||
|
||||
Состояние хранится в `./state/progress.db` (SQLite). Логи — в `./state/manga.log`.
|
||||
|
||||
## Дополнительные опции
|
||||
---
|
||||
|
||||
## Параметры CLI
|
||||
|
||||
| Флаг | Default | Описание |
|
||||
|------|---------|---------|
|
||||
| `--format / -f` | `cbz` | `cbz` \| `pdf` \| `epub` \| `all` |
|
||||
| `--chapters / -c` | — | Диапазон: `1-10`, `5`, `1,3,7` |
|
||||
| `--output / -o` | `./output` | Папка для сохранения |
|
||||
| `--resume` | включён | Пропускать уже скачанные главы |
|
||||
| `--force / -F` | — | Игнорировать БД, скачать заново |
|
||||
| `--concurrency` | `4` | Параллельных загрузок |
|
||||
| `--verbose / -v` | — | Подробный вывод (DEBUG) |
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (docker-compose.yml)
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
|
||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
||||
|
||||
---
|
||||
|
||||
## Возобновление прерванной загрузки
|
||||
|
||||
Загрузка автоматически продолжается с места остановки — состояние хранится в БД. При перезапуске контейнера незавершённые задачи восстанавливаются в очередь.
|
||||
|
||||
---
|
||||
|
||||
## Метаданные (Komga и другие читалки)
|
||||
|
||||
Каждый скачанный файл содержит полные метаданные серии и главы:
|
||||
|
||||
| Формат | Метаданные |
|
||||
|--------|-----------|
|
||||
| **CBZ** | `ComicInfo.xml` (Anansi v2.1) — серия, номер, том, описание, жанры, язык, ориентация (право→лево), ссылка на источник |
|
||||
| **EPUB** | Dublin Core + `calibre:series` + EPUB3 `belongs-to-collection` — серия, описание, порядок глав |
|
||||
| **PDF** | PDF `/Info` + XMP (Dublin Core) — название, описание, язык, источник |
|
||||
|
||||
Для завершённых серий (`pub_status = completed`) в `ComicInfo.xml` записывается поле `<Count>` — Komga отображает прогресс чтения серии.
|
||||
|
||||
```
|
||||
--format / -f cbz | pdf | epub | all (по умолчанию: cbz)
|
||||
--chapters / -c Диапазон или список глав
|
||||
--output / -o Папка для сохранения (по умолчанию: ./output)
|
||||
--resume Пропускать скачанные главы (по умолчанию: включено)
|
||||
--concurrency Параллельных загрузок (по умолчанию: 4)
|
||||
--verbose / -v Подробный вывод
|
||||
```
|
||||
|
||||
|
||||
107
analyze_speed.py
Normal file
107
analyze_speed.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
conn = sqlite3.connect('/app/state/progress.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
rows = conn.execute('''
|
||||
SELECT h.created_at, h.chapter_number, h.volume, m.title, h.manga_url
|
||||
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
|
||||
''').fetchall()
|
||||
|
||||
if not rows:
|
||||
print("История пуста")
|
||||
conn.close()
|
||||
exit()
|
||||
|
||||
times = [datetime.fromisoformat(r["created_at"]) for r in rows]
|
||||
total_dur = (times[-1] - times[0]).total_seconds()
|
||||
|
||||
print("=== ОБЩАЯ СТАТИСТИКА ===")
|
||||
print(f"Глав скачано: {len(rows)}")
|
||||
print(f"Период: {times[0].strftime('%d.%m %H:%M:%S')} — {times[-1].strftime('%d.%m %H:%M:%S')}")
|
||||
print(f"Общее время: {total_dur/3600:.2f} ч ({total_dur/60:.0f} мин)")
|
||||
print(f"Средняя скорость: {len(rows)/(total_dur/60):.2f} глав/мин ({total_dur/len(rows):.1f} сек/глава)")
|
||||
|
||||
# --- По мангам ---
|
||||
print("\n=== ПО МАНГАМ ===")
|
||||
by_manga = defaultdict(list)
|
||||
for i, r in enumerate(rows):
|
||||
by_manga[r["manga_url"]].append(times[i])
|
||||
|
||||
for url, ts in sorted(by_manga.items(), key=lambda x: x[1][0]):
|
||||
title = next((r["title"] for r in rows if r["manga_url"] == url and r["title"]), url[-40:])
|
||||
dur = (ts[-1] - ts[0]).total_seconds() if len(ts) > 1 else 0
|
||||
rate = len(ts) / (dur / 60) if dur > 0 else 0
|
||||
print(f" {(title or url)[:38]:38} {len(ts):4d} гл {dur/60:5.0f} мин {rate:.2f} гл/мин")
|
||||
|
||||
# --- По часам ---
|
||||
print("\n=== ГЛАВЫ ПО ЧАСАМ ===")
|
||||
by_hour = defaultdict(int)
|
||||
for t in times:
|
||||
by_hour[t.strftime('%d.%m %H:00')] += 1
|
||||
for hour, cnt in sorted(by_hour.items()):
|
||||
bar = '█' * min(cnt, 60)
|
||||
print(f" {hour} {cnt:4d} глав {bar}")
|
||||
|
||||
# --- Паузы > 5 мин ---
|
||||
print("\n=== ПАУЗЫ > 5 МИН (между главами) ===")
|
||||
big_gaps = []
|
||||
for i in range(len(times) - 1):
|
||||
sec = (times[i+1] - times[i]).total_seconds()
|
||||
if sec > 300:
|
||||
big_gaps.append((times[i], times[i+1], sec, rows[i]["title"], rows[i+1]["title"]))
|
||||
|
||||
if big_gaps:
|
||||
for t1, t2, sec, m1, m2 in big_gaps:
|
||||
same = (rows[big_gaps.index((t1,t2,sec,m1,m2)) if False else 0])
|
||||
label = f"{(m1 or '')[:20]} → {(m2 or '')[:20]}" if m1 != m2 else (m1 or "")[:40]
|
||||
print(f" {t1.strftime('%H:%M:%S')} → {t2.strftime('%H:%M:%S')} {sec/60:5.1f} мин [{label}]")
|
||||
else:
|
||||
print(" Пауз > 5 мин не обнаружено")
|
||||
|
||||
# --- Скорость по 10-мин окнам ---
|
||||
print("\n=== СКОРОСТЬ ПО 10-МИНУТНЫМ ОКНАМ ===")
|
||||
window = 10 * 60
|
||||
bucket_start = times[0]
|
||||
bucket_count = 0
|
||||
windows = []
|
||||
for t in times:
|
||||
if (t - bucket_start).total_seconds() < window:
|
||||
bucket_count += 1
|
||||
else:
|
||||
elapsed = (t - bucket_start).total_seconds()
|
||||
windows.append((bucket_start, bucket_count, elapsed))
|
||||
bucket_start = t
|
||||
bucket_count = 1
|
||||
if bucket_count:
|
||||
windows.append((bucket_start, bucket_count, (times[-1] - bucket_start).total_seconds() or 1))
|
||||
|
||||
max_cnt = max(w[1] for w in windows) if windows else 1
|
||||
for ws, cnt, elapsed in windows:
|
||||
rate = cnt / (elapsed / 60) if elapsed > 0 else 0
|
||||
bar_len = int(cnt / max_cnt * 40)
|
||||
bar = '▓' * bar_len + '░' * (40 - bar_len)
|
||||
print(f" {ws.strftime('%H:%M')} {cnt:3d} гл {rate:4.1f}/мин |{bar}|")
|
||||
|
||||
# --- Перцентили интервалов ---
|
||||
gaps_sec = sorted((times[i+1] - times[i]).total_seconds() for i in range(len(times)-1))
|
||||
if gaps_sec:
|
||||
n = len(gaps_sec)
|
||||
print(f"\n=== ИНТЕРВАЛЫ МЕЖДУ ГЛАВАМИ ===")
|
||||
print(f" Минимум: {gaps_sec[0]:.1f} сек")
|
||||
print(f" Медиана: {gaps_sec[n//2]:.1f} сек")
|
||||
print(f" P90: {gaps_sec[int(n*0.9)]:.1f} сек")
|
||||
print(f" P99: {gaps_sec[int(n*0.99)]:.1f} сек")
|
||||
print(f" Максимум: {gaps_sec[-1]:.1f} сек ({gaps_sec[-1]/60:.1f} мин)")
|
||||
over_2min = sum(1 for g in gaps_sec if g > 120)
|
||||
over_5min = sum(1 for g in gaps_sec if g > 300)
|
||||
print(f" > 2 мин: {over_2min} ({over_2min/n*100:.1f}%)")
|
||||
print(f" > 5 мин: {over_5min} ({over_5min/n*100:.1f}%)")
|
||||
|
||||
conn.close()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
manga:
|
||||
build: .
|
||||
@@ -10,13 +8,11 @@ services:
|
||||
- ./state:/app/state
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
# Chromium требует достаточно /dev/shm
|
||||
- UPDATE_INTERVAL_HOURS=6
|
||||
ports:
|
||||
- "8000:8000"
|
||||
shm_size: "2gb"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
# Переопределяется при запуске через:
|
||||
# docker compose run manga download <URL> --format cbz
|
||||
# docker compose run manga analyze <URL>
|
||||
command: ["--help"]
|
||||
|
||||
|
||||
restart: unless-stopped
|
||||
# Веб-интерфейс: http://localhost:8000
|
||||
# CLI-команды:
|
||||
# docker compose run --rm --entrypoint "" manga python -m src.cli download <URL> --format cbz
|
||||
|
||||
1350
frontend/index.html
Normal file
1350
frontend/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ img2pdf==0.5.1
|
||||
ebooklib==0.18
|
||||
tqdm==4.66.4
|
||||
loguru==0.7.2
|
||||
|
||||
|
||||
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
websockets==12.0
|
||||
pypdf==4.2.0
|
||||
|
||||
779
src/api.py
Normal file
779
src/api.py
Normal file
@@ -0,0 +1,779 @@
|
||||
"""
|
||||
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from .state import StateDB
|
||||
from .worker import download_manga, check_for_updates
|
||||
from .exporter import patch_meta, MangaMeta
|
||||
|
||||
OUTPUT_DIR = Path("/app/output")
|
||||
FRONTEND_DIR = Path("/app/frontend")
|
||||
|
||||
app = FastAPI(title="Manga Downloader API")
|
||||
|
||||
# ── WebSocket менеджер ────────────────────────
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active: set[WebSocket] = set()
|
||||
|
||||
async def connect(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.active.add(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket):
|
||||
self.active.discard(ws)
|
||||
|
||||
async def broadcast(self, data: dict):
|
||||
dead = set()
|
||||
for ws in list(self.active):
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self.active -= dead
|
||||
|
||||
|
||||
ws_manager = ConnectionManager()
|
||||
|
||||
# ── Очередь загрузки ─────────────────────────
|
||||
|
||||
download_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
# url → asyncio.Task текущей загрузки
|
||||
active_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def queue_worker():
|
||||
"""Последовательно обрабатывает очередь загрузок. Перезапускается при краше."""
|
||||
while True:
|
||||
try:
|
||||
await _queue_worker_loop()
|
||||
except Exception as e:
|
||||
logger.error("queue_worker упал, перезапускаю через 5 сек: {}", e)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def _queue_worker_loop():
|
||||
while True:
|
||||
job = await download_queue.get()
|
||||
url = job["url"]
|
||||
fmt = job.get("fmt", "cbz")
|
||||
|
||||
# Проверяем, не была ли манга остановлена пока стояла в очереди
|
||||
skip = False
|
||||
db = StateDB()
|
||||
try:
|
||||
m = db.get_manga(url)
|
||||
if m and m["status"] == "stopped":
|
||||
logger.info("Воркер: пропускаю остановленную {}", url)
|
||||
skip = True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if skip:
|
||||
download_queue.task_done()
|
||||
continue
|
||||
|
||||
logger.info("Воркер: начинаю скачивать {}", url)
|
||||
dl_task = asyncio.create_task(download_manga(
|
||||
url=url,
|
||||
fmt=fmt,
|
||||
is_update=job.get("is_update", False),
|
||||
resume=job.get("resume", True),
|
||||
on_event=ws_manager.broadcast,
|
||||
))
|
||||
active_tasks[url] = dl_task
|
||||
try:
|
||||
await dl_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Воркер: загрузка прервана: {}", url)
|
||||
_db = StateDB()
|
||||
try:
|
||||
current_status = _db.get_manga(url)
|
||||
# Если статус уже "queued" — значит нас приоритизировали и поставили обратно
|
||||
# в очередь; не перетираем на "stopped"
|
||||
if current_status and current_status["status"] != "queued":
|
||||
_db.update_manga_status(url, "stopped")
|
||||
await ws_manager.broadcast({"type": "manga_stopped", "url": url})
|
||||
else:
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": fmt})
|
||||
finally:
|
||||
_db.close()
|
||||
except Exception as e:
|
||||
logger.error("Воркер ошибка {}: {}", url, e)
|
||||
finally:
|
||||
active_tasks.pop(url, None)
|
||||
download_queue.task_done()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(queue_worker())
|
||||
asyncio.create_task(update_scheduler())
|
||||
# Восстанавливаем очередь из БД (незавершённые задачи)
|
||||
db = StateDB()
|
||||
try:
|
||||
for manga in db.get_all_mangas():
|
||||
if manga["status"] in ("queued", "downloading"):
|
||||
db.update_manga_status(manga["url"], "queued")
|
||||
await download_queue.put({"url": manga["url"], "fmt": manga["format"]})
|
||||
logger.info("Восстановлено из очереди: {}", manga["url"])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def update_scheduler():
|
||||
"""Периодически проверяет новые главы для манг с auto_update=1."""
|
||||
interval_hours = float(os.getenv("UPDATE_INTERVAL_HOURS", "6"))
|
||||
interval_sec = interval_hours * 3600
|
||||
logger.info("Планировщик обновлений: каждые {} ч", interval_hours)
|
||||
# Первый запуск — через 5 минут после старта
|
||||
await asyncio.sleep(300)
|
||||
while True:
|
||||
await _run_auto_updates()
|
||||
await asyncio.sleep(interval_sec)
|
||||
|
||||
|
||||
async def _run_auto_updates():
|
||||
"""Проверяет все манги с auto_update=1 на наличие новых глав."""
|
||||
db = StateDB()
|
||||
try:
|
||||
candidates = db.get_autos()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
logger.info("Авто-обновление: проверяем {} манг", len(candidates))
|
||||
for manga in candidates:
|
||||
url = manga["url"]
|
||||
fmt = manga.get("format", "cbz")
|
||||
try:
|
||||
new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast)
|
||||
if new_chapters:
|
||||
logger.info("Новых глав для {}: {}", url, len(new_chapters))
|
||||
# Добавляем в очередь с флагом is_update
|
||||
db2 = StateDB()
|
||||
try:
|
||||
status = db2.get_manga(url)
|
||||
if status and status["status"] not in ("downloading", "queued"):
|
||||
db2.update_manga_status(url, "queued")
|
||||
finally:
|
||||
db2.close()
|
||||
await download_queue.put({"url": url, "fmt": fmt, "is_update": True})
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_queued",
|
||||
"url": url,
|
||||
"format": fmt,
|
||||
"reason": "auto_update",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Ошибка авто-обновления {}: {}", url, e)
|
||||
|
||||
|
||||
# ── Вспомогательные функции ───────────────────
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
"""Размер директории в байтах."""
|
||||
if not path.exists():
|
||||
return 0
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
def _format_size(bytes_val: int) -> str:
|
||||
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
||||
if bytes_val < 1024:
|
||||
return f"{bytes_val:.1f} {unit}"
|
||||
bytes_val /= 1024
|
||||
return f"{bytes_val:.1f} ТБ"
|
||||
|
||||
|
||||
def _enrich_manga(m: dict, db: StateDB) -> dict:
|
||||
"""Обогащает строку манги реальными счётчиками из таблицы chapters."""
|
||||
title = m.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
size_bytes = _dir_size(OUTPUT_DIR / safe_title)
|
||||
ch_done_count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'",
|
||||
(m["url"],)
|
||||
).fetchone()[0]
|
||||
ch_failed = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='failed'",
|
||||
(m["url"],)
|
||||
).fetchone()[0]
|
||||
ch_partial = db.conn.execute(
|
||||
"""SELECT COUNT(*) FROM chapters
|
||||
WHERE manga_url=? AND status='done'
|
||||
AND pages_total > 0 AND pages_done < pages_total""",
|
||||
(m["url"],)
|
||||
).fetchone()[0]
|
||||
return {
|
||||
**m,
|
||||
"chapters_done": ch_done_count,
|
||||
"size_bytes": size_bytes,
|
||||
"size_human": _format_size(size_bytes),
|
||||
"queue_position": None,
|
||||
"is_active": m["url"] in active_tasks,
|
||||
"errors_count": ch_failed + ch_partial,
|
||||
"started_at": m.get("started_at"),
|
||||
"finished_at": m.get("finished_at"),
|
||||
}
|
||||
|
||||
|
||||
def _manga_detail(manga: dict, db: StateDB) -> dict:
|
||||
url = manga["url"]
|
||||
chapters = db.get_all_chapters(url)
|
||||
|
||||
# Определяем директорию манги
|
||||
title = manga.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
manga_dir = OUTPUT_DIR / safe_title
|
||||
size_bytes = _dir_size(manga_dir)
|
||||
|
||||
# Файлы
|
||||
files = []
|
||||
if manga_dir.exists():
|
||||
for f in sorted(manga_dir.iterdir()):
|
||||
if f.is_file():
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
"size_human": _format_size(f.stat().st_size),
|
||||
})
|
||||
|
||||
# ── Статистика ───────────────────────────
|
||||
ch_done = [c for c in chapters if c["status"] == "done"]
|
||||
ch_failed = [c for c in chapters if c["status"] == "failed"]
|
||||
ch_pending = [c for c in chapters if c["status"] == "pending"]
|
||||
|
||||
total_pages_downloaded = sum(c.get("pages_done", 0) for c in chapters)
|
||||
total_pages_expected = sum(c.get("pages_total", 0) for c in chapters if c.get("pages_total", 0) > 0)
|
||||
|
||||
# Частично скачанные (done, но pages_done < pages_total)
|
||||
ch_partial = [
|
||||
c for c in ch_done
|
||||
if c.get("pages_total", 0) > 0 and c.get("pages_done", 0) < c.get("pages_total", 0)
|
||||
]
|
||||
# Сколько страниц потеряно в частичных
|
||||
pages_missing = sum(
|
||||
c.get("pages_total", 0) - c.get("pages_done", 0)
|
||||
for c in ch_partial
|
||||
)
|
||||
|
||||
errors = []
|
||||
for c in ch_failed:
|
||||
errors.append({**c, "error_type": "failed", "error_label": "Глава не загружена"})
|
||||
for c in ch_partial:
|
||||
missing = c.get("pages_total", 0) - c.get("pages_done", 0)
|
||||
errors.append({**c, "error_type": "partial",
|
||||
"error_label": f"Частичная загрузка: пропущено {missing} стр."})
|
||||
# Сортируем: сначала failed, потом partial, внутри — по номеру
|
||||
errors.sort(key=lambda c: (0 if c["error_type"] == "failed" else 1, c.get("number", 0)))
|
||||
|
||||
stats = {
|
||||
"chapters_done": len(ch_done),
|
||||
"chapters_failed": len(ch_failed),
|
||||
"chapters_pending": len(ch_pending),
|
||||
"chapters_partial": len(ch_partial),
|
||||
"total_pages_downloaded": total_pages_downloaded,
|
||||
"total_pages_expected": total_pages_expected,
|
||||
"pages_missing": pages_missing,
|
||||
"errors_count": len(errors),
|
||||
}
|
||||
|
||||
return {
|
||||
**manga,
|
||||
"chapters": chapters,
|
||||
"files": files,
|
||||
"size_bytes": size_bytes,
|
||||
"size_human": _format_size(size_bytes),
|
||||
"files_count": len(files),
|
||||
"stats": stats,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ── REST API ──────────────────────────────────
|
||||
|
||||
class AddMangaRequest(BaseModel):
|
||||
urls: List[str]
|
||||
format: str = "cbz"
|
||||
|
||||
|
||||
@app.get("/api/mangas")
|
||||
async def list_mangas():
|
||||
db = StateDB()
|
||||
try:
|
||||
mangas = db.get_all_mangas()
|
||||
result = [_enrich_manga(m, db) for m in mangas]
|
||||
# Добавляем позицию в очереди
|
||||
queue_list = list(download_queue._queue) # type: ignore
|
||||
for i, job in enumerate(queue_list):
|
||||
for r in result:
|
||||
if r["url"] == job["url"]:
|
||||
r["queue_position"] = i + 1
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/mangas/detail")
|
||||
async def manga_detail(url: str):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
return _manga_detail(manga, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/queue")
|
||||
async def add_to_queue(body: AddMangaRequest):
|
||||
db = StateDB()
|
||||
added = []
|
||||
skipped = []
|
||||
try:
|
||||
for url in body.urls:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
is_new = db.add_manga(url, body.format)
|
||||
if is_new:
|
||||
await download_queue.put({"url": url, "fmt": body.format})
|
||||
added.append(url)
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_queued",
|
||||
"url": url,
|
||||
"format": body.format,
|
||||
})
|
||||
# Запускаем фоновую задачу предпросмотра (без Chromium — быстро)
|
||||
asyncio.create_task(_fetch_preview(url))
|
||||
else:
|
||||
skipped.append(url)
|
||||
finally:
|
||||
db.close()
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
|
||||
async def _fetch_preview(url: str):
|
||||
"""Быстро получает название и количество глав сразу после добавления."""
|
||||
try:
|
||||
from .browser import BrowserManager
|
||||
from .scraper import get_manga_info
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
_, page = await bm.new_page()
|
||||
manga = await get_manga_info(page, url)
|
||||
if not manga:
|
||||
return
|
||||
db = StateDB()
|
||||
try:
|
||||
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,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_preview",
|
||||
"url": url,
|
||||
"title": manga.title_ru or manga.title,
|
||||
"title_ru": manga.title_ru,
|
||||
"title_full": manga.title_full,
|
||||
"pub_status": manga.pub_status,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
logger.info("Предпросмотр готов: {} ({} глав)", manga.title_ru or manga.title, len(manga.chapters))
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка предпросмотра {}: {}", url, e)
|
||||
|
||||
|
||||
@app.post("/api/mangas/auto_update")
|
||||
async def toggle_auto_update(url: str, enabled: bool):
|
||||
"""Включить/выключить авто-обновление для манги."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
db.set_auto_update(url, enabled)
|
||||
await ws_manager.broadcast({
|
||||
"type": "auto_update_changed",
|
||||
"url": url,
|
||||
"auto_update": enabled,
|
||||
})
|
||||
return {"ok": True, "auto_update": enabled}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/check_now")
|
||||
async def check_now(url: str):
|
||||
"""Немедленно проверить новые главы для конкретной манги."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
finally:
|
||||
db.close()
|
||||
asyncio.create_task(_check_and_queue(url))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _check_and_queue(url: str):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
fmt = manga["format"] if manga else "cbz"
|
||||
finally:
|
||||
db.close()
|
||||
new = await check_for_updates(url, on_event=ws_manager.broadcast)
|
||||
if new:
|
||||
db2 = StateDB()
|
||||
try:
|
||||
db2.update_manga_status(url, "queued")
|
||||
finally:
|
||||
db2.close()
|
||||
await download_queue.put({"url": url, "fmt": fmt, "is_update": True})
|
||||
|
||||
|
||||
@app.get("/api/news")
|
||||
async def get_news(limit: int = 100):
|
||||
"""Только скачанные и автодокаченные главы — для вкладки Новости."""
|
||||
db = StateDB()
|
||||
try:
|
||||
cur = db.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()]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
async def get_history(limit: int = 100, manga_url: str = ""):
|
||||
db = StateDB()
|
||||
try:
|
||||
return db.get_history(limit=limit, manga_url=manga_url)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/prioritize")
|
||||
async def prioritize_manga(url: str):
|
||||
"""Поместить мангу в начало очереди, прервав текущую загрузку и вернув её следом."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and url in active_tasks:
|
||||
return {"ok": True, "message": "Уже загружается"}
|
||||
|
||||
fmt = manga["format"] or "cbz"
|
||||
|
||||
# 1. Убираем target из очереди если там уже есть
|
||||
items = list(download_queue._queue) # type: ignore
|
||||
items = [i for i in items if i["url"] != url]
|
||||
download_queue._queue.clear() # type: ignore
|
||||
for item in items:
|
||||
download_queue._queue.append(item) # type: ignore
|
||||
|
||||
# 2. Текущая активная загрузка
|
||||
current_url = next(iter(active_tasks), None)
|
||||
if current_url and current_url != url:
|
||||
cur_manga = db.get_manga(current_url)
|
||||
cur_fmt = cur_manga["format"] if cur_manga else "cbz"
|
||||
# Помечаем как queued — воркер увидит это и не поставит stopped
|
||||
db.update_manga_status(current_url, "queued")
|
||||
# Вставляем обратно на второе место (сразу после target)
|
||||
download_queue._queue.appendleft({"url": current_url, "fmt": cur_fmt}) # type: ignore
|
||||
# Отменяем задачу — воркер сразу перейдёт к следующему элементу (target)
|
||||
task = active_tasks.get(current_url)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
# 3. Вставляем target в самое начало
|
||||
download_queue._queue.appendleft({"url": url, "fmt": fmt}) # type: ignore
|
||||
db.update_manga_status(url, "queued")
|
||||
|
||||
logger.info("Приоритет: {} → начало очереди (вытеснен: {})", url, current_url)
|
||||
await ws_manager.broadcast({
|
||||
"type": "manga_prioritized",
|
||||
"url": url,
|
||||
"preempted_url": current_url,
|
||||
})
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/retry_errors")
|
||||
async def retry_errors(url: str):
|
||||
"""Сбросить статус failed/partial глав на pending для повторной загрузки."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
# Сбрасываем failed
|
||||
db.conn.execute(
|
||||
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? WHERE manga_url=? AND status='failed'",
|
||||
(db.conn.execute("SELECT datetime('now')").fetchone()[0], url)
|
||||
)
|
||||
# Сбрасываем partial (done, но страниц скачано меньше)
|
||||
db.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""",
|
||||
(db.conn.execute("SELECT datetime('now')").fetchone()[0], url)
|
||||
)
|
||||
db.conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/refresh_meta")
|
||||
async def refresh_meta(url: str):
|
||||
"""Обновить метаданные (ComicInfo.xml / EPUB OPF / PDF XMP) во всех уже скачанных файлах."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and url in active_tasks:
|
||||
raise HTTPException(status_code=400, detail="Манга сейчас загружается")
|
||||
finally:
|
||||
db.close()
|
||||
asyncio.create_task(_do_refresh_meta(url))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _do_refresh_meta(url: str):
|
||||
"""Фоновая задача: обходит все скачанные файлы и обновляет метаданные."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
return
|
||||
chapters = db.get_all_chapters(url)
|
||||
chapters_total = len(chapters)
|
||||
pub_status = manga.get("pub_status", "unknown") or "unknown"
|
||||
|
||||
updated = failed = 0
|
||||
for ch in chapters:
|
||||
for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".epub")):
|
||||
fpath = ch.get(fmt_col)
|
||||
if not fpath:
|
||||
continue
|
||||
p = Path(fpath)
|
||||
if not p.exists():
|
||||
continue
|
||||
meta = MangaMeta(
|
||||
series=manga.get("title_ru") or manga.get("title") or "",
|
||||
series_full=manga.get("title_full") or "",
|
||||
chapter_title=ch.get("title") or "",
|
||||
number=float(ch.get("number") or 0),
|
||||
volume=int(ch.get("volume") or 0),
|
||||
chapters_total=chapters_total,
|
||||
pub_status=pub_status,
|
||||
source_url=url,
|
||||
)
|
||||
if patch_meta(p, meta):
|
||||
updated += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed)
|
||||
await ws_manager.broadcast({
|
||||
"type": "meta_refreshed",
|
||||
"url": url,
|
||||
"updated": updated,
|
||||
"failed": failed,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("_do_refresh_meta {}: {}", url, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/force_redownload")
|
||||
async def force_redownload(url: str):
|
||||
"""Сбросить все главы на pending и поставить мангу заново в очередь."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and url in active_tasks:
|
||||
raise HTTPException(status_code=400, detail="Сначала остановите загрузку")
|
||||
|
||||
# Сбрасываем все главы на pending
|
||||
db.conn.execute(
|
||||
"UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? WHERE manga_url=?",
|
||||
(db.conn.execute("SELECT datetime('now')").fetchone()[0], url)
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
# Ставим в очередь с resume=False — перекачает всё заново
|
||||
db.update_manga_status(url, "queued")
|
||||
await download_queue.put({"url": url, "fmt": manga["format"], "resume": False})
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/stop")
|
||||
async def stop_manga(url: str):
|
||||
"""Остановить текущую загрузку манги."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
|
||||
# Отменяем активную задачу если есть
|
||||
task = active_tasks.get(url)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
# Статус обновит воркер после CancelledError
|
||||
else:
|
||||
# Манга в очереди (ещё не начата) — просто помечаем как stopped
|
||||
db.update_manga_status(url, "stopped")
|
||||
await ws_manager.broadcast({"type": "manga_stopped", "url": url})
|
||||
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/mangas/resume")
|
||||
async def resume_manga(url: str):
|
||||
"""Возобновить загрузку остановленной/упавшей манги."""
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and url in active_tasks:
|
||||
raise HTTPException(status_code=400, detail="Манга уже загружается")
|
||||
|
||||
db.update_manga_status(url, "queued")
|
||||
await download_queue.put({"url": url, "fmt": manga["format"]})
|
||||
await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]})
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.delete("/api/mangas")
|
||||
async def delete_manga(url: str, delete_files: bool = False):
|
||||
db = StateDB()
|
||||
try:
|
||||
manga = db.get_manga(url)
|
||||
if not manga:
|
||||
raise HTTPException(status_code=404, detail="Манга не найдена")
|
||||
if manga["status"] == "downloading" and url in active_tasks:
|
||||
raise HTTPException(status_code=400, detail="Нельзя удалить активную загрузку")
|
||||
|
||||
deleted_size = 0
|
||||
if delete_files:
|
||||
title = manga.get("title") or ""
|
||||
safe_title = re.sub(r'[^\w\s\-]', '', title).strip().replace(" ", "_")[:80]
|
||||
manga_dir = OUTPUT_DIR / safe_title
|
||||
if manga_dir.exists() and manga_dir.is_dir():
|
||||
deleted_size = _dir_size(manga_dir)
|
||||
import shutil
|
||||
shutil.rmtree(str(manga_dir))
|
||||
logger.info("Удалена папка: {} ({} байт)", manga_dir, deleted_size)
|
||||
|
||||
db.conn.execute("DELETE FROM chapters WHERE manga_url=?", (url,))
|
||||
db.conn.execute("DELETE FROM history WHERE manga_url=?", (url,))
|
||||
db.conn.execute("DELETE FROM mangas WHERE url=?", (url,))
|
||||
db.conn.commit()
|
||||
return {"ok": True, "deleted_size": deleted_size}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def global_stats():
|
||||
db = StateDB()
|
||||
try:
|
||||
mangas = db.get_all_mangas()
|
||||
total_size = _dir_size(OUTPUT_DIR)
|
||||
return {
|
||||
"mangas_total": len(mangas),
|
||||
"mangas_done": sum(1 for m in mangas if m["status"] == "done"),
|
||||
"mangas_downloading": sum(1 for m in mangas if m["status"] == "downloading"),
|
||||
"mangas_queued": sum(1 for m in mangas if m["status"] == "queued"),
|
||||
"mangas_failed": sum(1 for m in mangas if m["status"] == "failed"),
|
||||
"mangas_stopped": sum(1 for m in mangas if m["status"] == "stopped"),
|
||||
"queue_size": download_queue.qsize(),
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_human": _format_size(total_size),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── WebSocket ─────────────────────────────────
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
await ws_manager.connect(ws)
|
||||
try:
|
||||
# Отправляем начальный снимок состояния
|
||||
db = StateDB()
|
||||
try:
|
||||
mangas = db.get_all_mangas()
|
||||
enriched = [_enrich_manga(m, db) for m in mangas]
|
||||
# Добавляем позицию в очереди
|
||||
queue_list = list(download_queue._queue) # type: ignore
|
||||
for i, job in enumerate(queue_list):
|
||||
for em in enriched:
|
||||
if em["url"] == job["url"]:
|
||||
em["queue_position"] = i + 1
|
||||
await ws.send_json({"type": "snapshot", "mangas": enriched})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
while True:
|
||||
# Держим соединение живым, ждём пинги
|
||||
data = await ws.receive_text()
|
||||
if data == "ping":
|
||||
await ws.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(ws)
|
||||
except Exception:
|
||||
ws_manager.disconnect(ws)
|
||||
|
||||
|
||||
# ── Статические файлы (фронтенд) ──────────────
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
||||
|
||||
30
src/cli.py
30
src/cli.py
@@ -59,23 +59,26 @@ def cli(ctx, verbose):
|
||||
help="Папка для сохранения", show_default=True)
|
||||
@click.option("--resume/--no-resume", default=True,
|
||||
help="Пропускать уже скачанные главы")
|
||||
@click.option("--force", "-F", is_flag=True, default=False,
|
||||
help="Игнорировать состояние и скачать заново, перезаписывая файлы")
|
||||
@click.option("--concurrency", default=4, show_default=True,
|
||||
help="Параллельных загрузок изображений")
|
||||
@click.pass_context
|
||||
def download(ctx, url, fmt, chapters, output, resume, concurrency):
|
||||
def download(ctx, url, fmt, chapters, output, resume, force, concurrency):
|
||||
"""Скачать мангу по URL страницы."""
|
||||
asyncio.run(_download(
|
||||
url=url,
|
||||
fmt=fmt,
|
||||
chapters_filter=chapters,
|
||||
output_dir=Path(output),
|
||||
resume=resume,
|
||||
resume=resume and not force,
|
||||
force=force,
|
||||
concurrency=concurrency,
|
||||
verbose=ctx.obj.get("verbose", False),
|
||||
))
|
||||
|
||||
|
||||
async def _download(url, fmt, chapters_filter, output_dir, resume, concurrency, verbose):
|
||||
async def _download(url, fmt, chapters_filter, output_dir, resume, force, concurrency, verbose):
|
||||
db = StateDB()
|
||||
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
@@ -106,8 +109,10 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, concurrency,
|
||||
for ch in chapters:
|
||||
pbar.set_description(f"Глава {ch.number}: {ch.title[:30]}")
|
||||
|
||||
# Проверяем статус (resume)
|
||||
if resume and db.chapter_status(ch.url) == "done":
|
||||
# Проверяем статус (resume / force)
|
||||
if force:
|
||||
db.reset_chapter(ch.url)
|
||||
elif resume and db.chapter_status(ch.url) == "done":
|
||||
logger.info("Пропускаем (уже скачана): {}", ch.title)
|
||||
pbar.update(1)
|
||||
continue
|
||||
@@ -116,7 +121,7 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, concurrency,
|
||||
bm=bm, ctx=ctx, ch=ch,
|
||||
manga_url=url,
|
||||
manga_dir=manga_dir, formats=formats,
|
||||
concurrency=concurrency, db=db,
|
||||
concurrency=concurrency, db=db, force=force,
|
||||
)
|
||||
pbar.update(1)
|
||||
|
||||
@@ -126,7 +131,7 @@ async def _download(url, fmt, chapters_filter, output_dir, resume, concurrency,
|
||||
|
||||
|
||||
async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path,
|
||||
formats: list, concurrency: int, db: StateDB):
|
||||
formats: list, concurrency: int, db: StateDB, force: bool = False):
|
||||
# Новая страница для каждой главы (чистый контекст)
|
||||
ch_page = await ctx.new_page()
|
||||
|
||||
@@ -147,6 +152,10 @@ async def _process_chapter(bm, ctx, ch: Chapter, manga_url: str, manga_dir: Path
|
||||
|
||||
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)
|
||||
db.mark_done(ch.url, fmt, str(out_file))
|
||||
@@ -243,3 +252,10 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
345
src/exporter.py
345
src/exporter.py
@@ -1,104 +1,278 @@
|
||||
"""
|
||||
Экспорт в CBZ, PDF, EPUB.
|
||||
Экспорт в CBZ, PDF, EPUB с поддержкой метаданных для Komga.
|
||||
"""
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
ExportFormat = Literal["cbz", "pdf", "epub"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MangaMeta:
|
||||
"""Метаданные манги и главы для встраивания в файлы."""
|
||||
series: str = "" # Название серии (title_ru)
|
||||
series_full: str = "" # Полное название
|
||||
chapter_title: str = "" # Название главы
|
||||
number: float = 0.0 # Номер главы
|
||||
volume: int = 0 # Том
|
||||
chapters_total: int = 0 # Всего глав в серии (для completed)
|
||||
pub_status: str = "unknown" # completed / ongoing / unknown
|
||||
source_url: str = "" # URL источника
|
||||
language: str = "ru"
|
||||
summary: str = "" # Описание/синопсис серии
|
||||
genre: str = "" # Жанры через запятую (для ComicInfo Genre)
|
||||
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
|
||||
|
||||
|
||||
def export(
|
||||
image_paths: list[Path],
|
||||
output_path: Path,
|
||||
fmt: ExportFormat,
|
||||
title: str = "Manga",
|
||||
chapter: str = "",
|
||||
meta: Optional[MangaMeta] = None,
|
||||
):
|
||||
# Строим meta из legacy-аргументов если не передан явно
|
||||
if meta is None:
|
||||
meta = MangaMeta(series=title, chapter_title=chapter)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Экспортирую {} страниц → {} ({})", len(image_paths), output_path.name, fmt)
|
||||
|
||||
if fmt == "cbz":
|
||||
_export_cbz(image_paths, output_path)
|
||||
_export_cbz(image_paths, output_path, meta)
|
||||
elif fmt == "pdf":
|
||||
_export_pdf(image_paths, output_path)
|
||||
_export_pdf(image_paths, output_path, meta)
|
||||
elif fmt == "epub":
|
||||
_export_epub(image_paths, output_path, title, chapter)
|
||||
_export_epub(image_paths, output_path, meta)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный формат: {fmt}")
|
||||
|
||||
logger.info("Сохранено: {}", output_path)
|
||||
|
||||
|
||||
# ── CBZ ───────────────────────────────────────
|
||||
# ── CBZ + ComicInfo.xml ───────────────────────
|
||||
|
||||
def _export_cbz(images: list[Path], out: Path):
|
||||
def _make_comic_info(meta: MangaMeta) -> str:
|
||||
"""Генерирует ComicInfo.xml по спецификации Anansi v2.1 (Komga-совместимый)."""
|
||||
root = ET.Element("ComicInfo")
|
||||
root.set("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
root.set("xsi:noNamespaceSchemaLocation",
|
||||
"https://raw.githubusercontent.com/anansi-project/comicinfo/main/schema/v2.1/ComicInfo.xsd")
|
||||
|
||||
def add(tag: str, value):
|
||||
if value is None:
|
||||
return
|
||||
s = str(value).strip()
|
||||
if s:
|
||||
ET.SubElement(root, tag).text = s
|
||||
|
||||
add("Series", meta.series)
|
||||
add("Title", meta.chapter_title)
|
||||
add("Summary", meta.summary)
|
||||
|
||||
# Номер главы: целое если без дроби, иначе float
|
||||
if meta.number:
|
||||
num_str = str(int(meta.number)) if meta.number == int(meta.number) else str(meta.number)
|
||||
add("Number", num_str)
|
||||
|
||||
if meta.volume:
|
||||
add("Volume", meta.volume)
|
||||
|
||||
# Count — только для завершённых серий
|
||||
if meta.pub_status == "completed" and meta.chapters_total:
|
||||
add("Count", meta.chapters_total)
|
||||
|
||||
add("Genre", meta.genre)
|
||||
add("LanguageISO", meta.language)
|
||||
|
||||
# Manga = YesAndRightToLeft — стандартная японская манга
|
||||
ET.SubElement(root, "Manga").text = "YesAndRightToLeft"
|
||||
|
||||
if meta.source_url:
|
||||
add("Web", meta.source_url)
|
||||
|
||||
# SeriesGroup — Komga создаёт коллекцию с этим именем
|
||||
if meta.series_group:
|
||||
add("SeriesGroup", meta.series_group)
|
||||
|
||||
ET.indent(root, space=" ")
|
||||
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _export_cbz(images: list[Path], out: Path, meta: MangaMeta):
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# ComicInfo.xml первым файлом — Komga ищет его в корне архива
|
||||
zf.writestr("ComicInfo.xml", _make_comic_info(meta))
|
||||
for i, img in enumerate(images):
|
||||
zf.write(img, f"{i:04d}{img.suffix}")
|
||||
|
||||
|
||||
# ── PDF ───────────────────────────────────────
|
||||
|
||||
def _export_pdf(images: list[Path], out: Path):
|
||||
def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
|
||||
try:
|
||||
import img2pdf
|
||||
with open(out, "wb") as f:
|
||||
f.write(img2pdf.convert([str(p) for p in images]))
|
||||
pdf_bytes = img2pdf.convert([str(p) for p in images])
|
||||
out.write_bytes(pdf_bytes)
|
||||
except Exception as e:
|
||||
logger.warning("img2pdf не сработал ({}), использую Pillow", e)
|
||||
_export_pdf_pillow(images, out)
|
||||
|
||||
# Записываем метаданные поверх готового PDF через pypdf
|
||||
_patch_pdf_meta(out, meta)
|
||||
|
||||
|
||||
def _export_pdf_pillow(images: list[Path], out: Path):
|
||||
from PIL import Image
|
||||
pil_images = []
|
||||
for p in images:
|
||||
img = Image.open(p).convert("RGB")
|
||||
pil_images.append(img)
|
||||
pil_images = [Image.open(p).convert("RGB") for p in images]
|
||||
if pil_images:
|
||||
pil_images[0].save(
|
||||
out,
|
||||
save_all=True,
|
||||
append_images=pil_images[1:],
|
||||
format="PDF",
|
||||
)
|
||||
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
|
||||
|
||||
|
||||
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
|
||||
"""Добавляет /Info и XMP метаданные в PDF через pypdf."""
|
||||
try:
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
import io
|
||||
|
||||
reader = PdfReader(str(pdf_path))
|
||||
writer = PdfWriter()
|
||||
writer.append(reader)
|
||||
|
||||
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
|
||||
full_title = (f"{meta.series} — Том {meta.volume}, Глава {ch_num}"
|
||||
if meta.volume else f"{meta.series} — Глава {ch_num}")
|
||||
if meta.chapter_title:
|
||||
full_title += f": {meta.chapter_title}"
|
||||
|
||||
# Стандартные PDF /Info поля
|
||||
writer.add_metadata({
|
||||
"/Title": full_title,
|
||||
"/Subject": meta.series_full or meta.series,
|
||||
"/Creator": "Manga Downloader",
|
||||
"/Producer": "Manga Downloader",
|
||||
})
|
||||
|
||||
# XMP-метаданные (Dublin Core + PDF) — Komga читает их при сканировании
|
||||
xmp = _build_xmp(meta, full_title)
|
||||
writer.add_metadata_xmp(xmp.encode("utf-8"))
|
||||
|
||||
buf = io.BytesIO()
|
||||
writer.write(buf)
|
||||
pdf_path.write_bytes(buf.getvalue())
|
||||
|
||||
except ImportError:
|
||||
logger.debug("pypdf не установлен — PDF-метаданные пропущены")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка записи PDF-метаданных: {}", e)
|
||||
|
||||
|
||||
def _build_xmp(meta: MangaMeta, full_title: str) -> str:
|
||||
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
|
||||
return f"""<?xpacket begin='\ufeff' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
||||
<x:xmpmeta xmlns:x='adobe:ns:meta/'>
|
||||
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
|
||||
<rdf:Description rdf:about=''
|
||||
xmlns:dc='http://purl.org/dc/elements/1.1/'
|
||||
xmlns:pdf='http://ns.adobe.com/pdf/1.3/'
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<dc:title><rdf:Alt><rdf:li xml:lang='x-default'>{_xe(full_title)}</rdf:li></rdf:Alt></dc:title>
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang='x-default'>{_xe(meta.series_full or meta.series)}</rdf:li></rdf:Alt></dc:description>
|
||||
<dc:language><rdf:Bag><rdf:li>{meta.language}</rdf:li></rdf:Bag></dc:language>
|
||||
<dc:source>{_xe(meta.source_url)}</dc:source>
|
||||
<pdf:Producer>Manga Downloader</pdf:Producer>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end='w'?>"""
|
||||
|
||||
|
||||
def _xe(s: str) -> str:
|
||||
"""Экранирование для XML."""
|
||||
return (s.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
|
||||
# ── EPUB ──────────────────────────────────────
|
||||
|
||||
def _export_epub(images: list[Path], out: Path, title: str, chapter: str):
|
||||
def _export_epub(images: list[Path], out: Path, meta: MangaMeta):
|
||||
from ebooklib import epub
|
||||
from PIL import Image
|
||||
import base64
|
||||
|
||||
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
|
||||
full_title = (f"{meta.series} — Том {meta.volume}, Глава {ch_num}"
|
||||
if meta.volume else f"{meta.series} — Глава {ch_num}")
|
||||
if meta.chapter_title:
|
||||
full_title += f": {meta.chapter_title}"
|
||||
|
||||
book = epub.EpubBook()
|
||||
book.set_identifier(f"manga-{title}-{chapter}".replace(" ", "-"))
|
||||
book.set_title(f"{title} — {chapter}" if chapter else title)
|
||||
book.set_language("ru")
|
||||
book.set_identifier(
|
||||
f"manga-{meta.series}-v{meta.volume}-ch{meta.number}".replace(" ", "-")
|
||||
)
|
||||
book.set_title(full_title)
|
||||
book.set_language(meta.language)
|
||||
|
||||
# Dublin Core — серия как subject
|
||||
if meta.series:
|
||||
book.add_metadata("DC", "subject", meta.series)
|
||||
if meta.summary:
|
||||
book.add_metadata("DC", "description", meta.summary)
|
||||
elif meta.series_full:
|
||||
book.add_metadata("DC", "description", meta.series_full)
|
||||
if meta.source_url:
|
||||
book.add_metadata("DC", "source", meta.source_url)
|
||||
|
||||
# Calibre-совместимые метаданные серии (читает Komga и большинство читалок)
|
||||
book.add_metadata(None, "meta", "", {
|
||||
"name": "calibre:series",
|
||||
"content": meta.series,
|
||||
})
|
||||
book.add_metadata(None, "meta", "", {
|
||||
"name": "calibre:series_index",
|
||||
"content": str(float(meta.number)),
|
||||
})
|
||||
|
||||
# EPUB3 belongs-to-collection (официальный стандарт, Komga ≥ 0.157)
|
||||
book.add_metadata(None, "meta", meta.series, {
|
||||
"property": "belongs-to-collection",
|
||||
"id": "series-id",
|
||||
})
|
||||
book.add_metadata(None, "meta", "series", {
|
||||
"refines": "#series-id",
|
||||
"property": "collection-type",
|
||||
})
|
||||
book.add_metadata(None, "meta", str(float(meta.number)), {
|
||||
"refines": "#series-id",
|
||||
"property": "group-position",
|
||||
})
|
||||
|
||||
# Если серия завершена — указываем общее количество томов
|
||||
if meta.pub_status == "completed" and meta.chapters_total:
|
||||
book.add_metadata("DC", "relation",
|
||||
f"chapters_total:{meta.chapters_total}")
|
||||
|
||||
spine = ["nav"]
|
||||
toc = []
|
||||
|
||||
for i, img_path in enumerate(images):
|
||||
# Добавляем изображение в книгу
|
||||
with open(img_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
|
||||
img_data = img_path.read_bytes()
|
||||
img_name = f"images/page_{i:04d}{img_path.suffix}"
|
||||
|
||||
epub_img = epub.EpubImage()
|
||||
epub_img.file_name = img_name
|
||||
epub_img.media_type = _mime(img_path.suffix)
|
||||
epub_img.content = img_data
|
||||
book.add_item(epub_img)
|
||||
|
||||
# HTML-страница для каждого изображения
|
||||
page_html = epub.EpubHtml(
|
||||
title=f"Страница {i + 1}",
|
||||
file_name=f"page_{i:04d}.xhtml",
|
||||
lang="ru",
|
||||
lang=meta.language,
|
||||
)
|
||||
page_html.content = (
|
||||
f'<html><body style="margin:0;padding:0;">'
|
||||
@@ -125,3 +299,110 @@ def _mime(ext: str) -> str:
|
||||
".webp": "image/webp",
|
||||
}.get(ext.lower(), "image/jpeg")
|
||||
|
||||
|
||||
# ── Обновление метаданных в существующих файлах ──
|
||||
|
||||
def patch_meta(file_path: Path, meta: MangaMeta) -> bool:
|
||||
"""
|
||||
Обновляет метаданные в уже существующем файле без перескачивания.
|
||||
Возвращает True при успехе.
|
||||
"""
|
||||
suffix = file_path.suffix.lower()
|
||||
try:
|
||||
if suffix == ".cbz":
|
||||
_patch_cbz_meta(file_path, meta)
|
||||
elif suffix == ".pdf":
|
||||
_patch_pdf_meta(file_path, meta)
|
||||
elif suffix == ".epub":
|
||||
_patch_epub_meta(file_path, meta)
|
||||
else:
|
||||
logger.warning("patch_meta: неизвестный формат {}", suffix)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("patch_meta {}: {}", file_path.name, e)
|
||||
return False
|
||||
|
||||
|
||||
def _patch_cbz_meta(cbz_path: Path, meta: MangaMeta):
|
||||
"""Заменяет или добавляет ComicInfo.xml в существующем CBZ."""
|
||||
import shutil
|
||||
tmp = cbz_path.with_suffix(".tmp.cbz")
|
||||
try:
|
||||
with zipfile.ZipFile(cbz_path, "r") as zin, \
|
||||
zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
# Сначала ComicInfo.xml
|
||||
zout.writestr("ComicInfo.xml", _make_comic_info(meta))
|
||||
# Затем все остальные файлы (пропускаем старый ComicInfo.xml если был)
|
||||
for item in zin.infolist():
|
||||
if item.filename.lower() != "comicinfo.xml":
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
shutil.move(str(tmp), str(cbz_path))
|
||||
except Exception:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
raise
|
||||
|
||||
|
||||
def _patch_epub_meta(epub_path: Path, meta: MangaMeta):
|
||||
"""
|
||||
Обновляет OPF-метаданные в существующем EPUB.
|
||||
Перезаписывает content.opf с новыми dc:* и meta-тегами.
|
||||
"""
|
||||
import shutil
|
||||
import re as _re
|
||||
|
||||
tmp = epub_path.with_suffix(".tmp.epub")
|
||||
try:
|
||||
with zipfile.ZipFile(epub_path, "r") as zin, \
|
||||
zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
|
||||
# Находим путь к OPF внутри EPUB
|
||||
opf_path = None
|
||||
if "META-INF/container.xml" in zin.namelist():
|
||||
container_xml = zin.read("META-INF/container.xml").decode("utf-8")
|
||||
m = _re.search(r'full-path=["\']([^"\']+\.opf)["\']', container_xml)
|
||||
if m:
|
||||
opf_path = m.group(1)
|
||||
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
if opf_path and item.filename == opf_path:
|
||||
data = _inject_opf_meta(data.decode("utf-8"), meta).encode("utf-8")
|
||||
zout.writestr(item, data)
|
||||
|
||||
shutil.move(str(tmp), str(epub_path))
|
||||
except Exception:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
raise
|
||||
|
||||
|
||||
def _inject_opf_meta(opf: str, meta: MangaMeta) -> str:
|
||||
"""
|
||||
Вставляет/заменяет calibre:series и belongs-to-collection в OPF-строку.
|
||||
Удаляет старые вхождения и добавляет свежие перед </metadata>.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
# Удаляем старые calibre и belongs-to-collection мета-теги
|
||||
opf = _re.sub(
|
||||
r'<meta[^>]+(?:calibre:series|belongs-to-collection|collection-type|group-position)[^/]*/?>',
|
||||
'', opf, flags=_re.IGNORECASE
|
||||
)
|
||||
# Удаляем старые refines на series-id
|
||||
opf = _re.sub(r'<meta[^>]+refines=["\']#series-id["\'][^/]*/?>',
|
||||
'', opf, flags=_re.IGNORECASE)
|
||||
|
||||
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
|
||||
new_meta = (
|
||||
f'\n <meta name="calibre:series" content="{_xe(meta.series)}"/>'
|
||||
f'\n <meta name="calibre:series_index" content="{float(meta.number)}"/>'
|
||||
f'\n <meta property="belongs-to-collection" id="series-id">{_xe(meta.series)}</meta>'
|
||||
f'\n <meta refines="#series-id" property="collection-type">series</meta>'
|
||||
f'\n <meta refines="#series-id" property="group-position">{float(meta.number)}</meta>'
|
||||
)
|
||||
opf = opf.replace("</metadata>", new_meta + "\n </metadata>")
|
||||
return opf
|
||||
|
||||
|
||||
|
||||
381
src/scraper.py
381
src/scraper.py
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -30,6 +31,11 @@ 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) # Жанры
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -43,9 +49,21 @@ async def get_manga_info(page: Page, url: str) -> Optional[MangaInfo]:
|
||||
if not ok:
|
||||
return None
|
||||
|
||||
title = await page.title()
|
||||
title = re.sub(r"\s*[-–|].*$", "", title).strip()
|
||||
logger.info("Манга: {}", title)
|
||||
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)
|
||||
@@ -53,7 +71,162 @@ async def get_manga_info(page: Page, url: str) -> Optional[MangaInfo]:
|
||||
chapters = await _extract_chapters_alt(page)
|
||||
|
||||
logger.info("Найдено глав: {}", len(chapters))
|
||||
return MangaInfo(title=title, url=url, chapters=chapters)
|
||||
return MangaInfo(
|
||||
title=title_ru or title_full,
|
||||
url=url,
|
||||
chapters=chapters,
|
||||
pub_status=pub_status,
|
||||
title_ru=title_ru,
|
||||
title_full=title_full,
|
||||
description=description,
|
||||
genres=genres,
|
||||
)
|
||||
|
||||
|
||||
async def _extract_ru_title_from_dom(page: Page) -> str:
|
||||
"""Ищет русский тайтл в структуре страницы readmanga."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
// readmanga: основной тайтл в span.name внутри .names
|
||||
const selectors = [
|
||||
'.names .name',
|
||||
'h1.manga-title',
|
||||
'h1 .name',
|
||||
'.name-block .name',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
""")
|
||||
return (result or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_ru_title(full_title: str) -> str:
|
||||
"""Извлекает русский тайтл из полной строки тайтла.
|
||||
|
||||
Примеры:
|
||||
'Манга Режим — АД. Хардкорный геймер ... (Hellmode)' → 'Режим — АД. Хардкорный геймер ...'
|
||||
'Манга Магическая битва (Sorcery Fight) Гэгэ онлайн' → 'Магическая битва'
|
||||
'Авантюрист Monster Eater Adventurer' → 'Авантюрист'
|
||||
"""
|
||||
t = full_title.strip()
|
||||
# Убираем префикс "Манга "
|
||||
t = re.sub(r'^Манга\s+', '', t).strip()
|
||||
# Берём только до первой скобки (начало английского тайтла)
|
||||
t = re.split(r'\s*[\(\[]', t)[0].strip()
|
||||
# Убираем суффикс " онлайн"
|
||||
t = re.sub(r'\s+онлайн\s*$', '', t, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Обрезаем хвост из латинских слов.
|
||||
# Правило: стоп только на токене содержащем латиницу (a-zA-Z).
|
||||
# Пунктуация между кириллическими словами (—, –, ., :, !) — сохраняем.
|
||||
words = t.split()
|
||||
result = []
|
||||
for w in words:
|
||||
if re.search(r'[а-яёА-ЯЁ]', w):
|
||||
result.append(w)
|
||||
elif re.search(r'[a-zA-Z]', w):
|
||||
# Первое латинское слово после кириллических — обрезаем здесь
|
||||
if result:
|
||||
break
|
||||
else:
|
||||
# Чисто пунктуационный токен (—, –, ., :, …)
|
||||
# Добавляем только если уже есть кириллические слова (связка внутри)
|
||||
if result:
|
||||
result.append(w)
|
||||
|
||||
# Убираем висячую пунктуацию в конце (если последнее слово — не кириллица)
|
||||
while result and not re.search(r'[а-яёА-ЯЁ]', result[-1]):
|
||||
result.pop()
|
||||
|
||||
if result:
|
||||
t = ' '.join(result)
|
||||
return t
|
||||
|
||||
|
||||
async def _extract_pub_status(page: Page) -> str:
|
||||
"""Извлекает статус выпуска: completed / ongoing / unknown."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
// readmanga хранит статус в .elem_status .value или похожих блоках
|
||||
const statusSelectors = [
|
||||
'.elem_status .value',
|
||||
'.manga-info .status',
|
||||
'[class*="status"] .value',
|
||||
'.property .status',
|
||||
];
|
||||
for (const sel of statusSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const t = el.textContent.toLowerCase();
|
||||
if (t.includes('завершён') || t.includes('завершен') || t.includes('complete')) return 'completed';
|
||||
if (t.includes('продолжает') || t.includes('ongoing')) return 'ongoing';
|
||||
}
|
||||
}
|
||||
// Fallback: сканируем весь текст страницы
|
||||
const bodyText = document.body ? document.body.innerText.toLowerCase() : '';
|
||||
if (bodyText.includes('выпуск завершён') || bodyText.includes('выпуск завершен')) return 'completed';
|
||||
if (bodyText.includes('продолжается')) return 'ongoing';
|
||||
return 'unknown';
|
||||
}
|
||||
""")
|
||||
return result or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _extract_description(page: Page) -> str:
|
||||
"""Извлекает описание/синопсис манги."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const selectors = [
|
||||
'.manga-description',
|
||||
'.elem_descr .value',
|
||||
'#tab-description .description-text',
|
||||
'.description',
|
||||
'[itemprop="description"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.textContent.trim()) return el.textContent.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
""")
|
||||
return (result or "").strip()[:2000] # обрезаем до 2000 символов
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _extract_genres(page: Page) -> list[str]:
|
||||
"""Извлекает список жанров манги."""
|
||||
try:
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const selectors = [
|
||||
'.elem_genre .value a',
|
||||
'.genres a',
|
||||
'[itemprop="genre"]',
|
||||
'.genre-list a',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const els = document.querySelectorAll(sel);
|
||||
if (els.length) return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
""")
|
||||
return result or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _navigate(page: Page, url: str, retries: int = 3,
|
||||
@@ -218,6 +391,7 @@ async def get_chapter_images_and_download(
|
||||
chapter_url: str,
|
||||
dest_dir: Path,
|
||||
manga_url: str | None = None,
|
||||
on_page: object = None,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
1. Открывает страницу главы (устанавливает DDoS-Guard cookies для CDN).
|
||||
@@ -225,8 +399,11 @@ async def get_chapter_images_and_download(
|
||||
3. Перехватывает img-запросы через page.route() + route.fetch()
|
||||
(браузерный стек — правильные Sec-Fetch-* заголовки, cookies).
|
||||
4. Пролистывает читалку клавишей ArrowRight чтобы загрузить все страницы.
|
||||
5. Retry для страниц с timeout через JS fetch.
|
||||
"""
|
||||
logger.info("Загружаем главу: {}", chapter_url)
|
||||
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)
|
||||
@@ -240,22 +417,20 @@ async def get_chapter_images_and_download(
|
||||
def _base(u: str) -> str:
|
||||
return u.split("?")[0]
|
||||
|
||||
# CDN домены которые хостят изображения манги (не статику сайта)
|
||||
CDN_RE = re.compile(r"(?<!\bstatic\b)(^|[./])one-way\.work|staticfa\.|cdnmanga|reimg", re.I)
|
||||
IMG_RE = re.compile(r"\.(jpg|jpeg|png|webp)(\?|$)", re.I)
|
||||
# Баннеры/рекламные изображения — игнорируем без логирования
|
||||
BANNER_RE = re.compile(r"466_p\.|570_p\.|banner|advert", re.I)
|
||||
|
||||
# Более точный фильтр: только image-хосты, не resrmr/статика
|
||||
def _is_manga_image(url: str) -> bool:
|
||||
base = _base(url)
|
||||
if not IMG_RE.search(base):
|
||||
if not re.search(r"\.(jpg|jpeg|png|webp)(\?|$)", base, re.I):
|
||||
return False
|
||||
# Исключаем статику сайта (логотипы, иконки, шрифты)
|
||||
if "resrmr." in url or "/static/" in url:
|
||||
return False
|
||||
# Принимаем image CDN
|
||||
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):
|
||||
@@ -264,23 +439,47 @@ async def get_chapter_images_and_download(
|
||||
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 response.status in (200, 206):
|
||||
if body and len(body) > 500 and status in (200, 206):
|
||||
async with lock:
|
||||
if base not in captured:
|
||||
captured[base] = body
|
||||
logger.debug("✓ {}: {} байт", base.split("/")[-1], len(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:
|
||||
logger.debug("route.fetch {}: {}", base[-40:], 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:
|
||||
@@ -292,7 +491,7 @@ async def get_chapter_images_and_download(
|
||||
ok = await _navigate(page, load_url, referer=referer)
|
||||
if not ok:
|
||||
await page.unroute("**/*", route_handler)
|
||||
logger.error("Не удалось открыть главу: {}", chapter_url)
|
||||
logger.error("[{}] Не удалось открыть главу после всех retry: {}", ch_id, chapter_url)
|
||||
return []
|
||||
|
||||
# 2. Ждём readerInit
|
||||
@@ -302,63 +501,165 @@ async def get_chapter_images_and_download(
|
||||
".some(s => s.textContent.includes('readerInit'))",
|
||||
timeout=15_000,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("readerInit не появился за 15с")
|
||||
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)
|
||||
logger.error("Список изображений пуст: {}", chapter_url)
|
||||
try:
|
||||
page_info = await page.evaluate("() => document.title + ' | ' + location.href")
|
||||
except Exception:
|
||||
page_info = "?"
|
||||
logger.error("[{}] Список изображений пуст. Текущая страница: {}", ch_id, page_info)
|
||||
return []
|
||||
|
||||
logger.info("Найдено изображений: {}", len(image_urls))
|
||||
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)
|
||||
|
||||
# 4. Пролистываем читалку — reader грузит страницы по мере листания
|
||||
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)
|
||||
for i in range(total + 10):
|
||||
async with lock:
|
||||
done = len(captured)
|
||||
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:
|
||||
except Exception as e:
|
||||
logger.warning("[{}] Ошибка листания на шаге {}: {}", ch_id, i + 1, e)
|
||||
break
|
||||
if i % 20 == 19:
|
||||
async with lock:
|
||||
done = len(captured)
|
||||
logger.debug("Пролистано {}, загружено: {}/{}", i + 1, done, total)
|
||||
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)
|
||||
|
||||
async with lock:
|
||||
done = len(captured)
|
||||
logger.info("Перехвачено: {}/{}", done, total)
|
||||
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)}
|
||||
|
||||
# 5. Сохраняем в правильном порядке
|
||||
paths: dict[int, Path] = {}
|
||||
unmatched_other: list[str] = []
|
||||
for base_url, body in captured.items():
|
||||
if base_url not in url_to_idx:
|
||||
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
|
||||
idx = url_to_idx[base_url]
|
||||
ext = _get_ext(base_url)
|
||||
p = dest_dir / f"{idx:04d}{ext}"
|
||||
p.write_bytes(body)
|
||||
paths[idx] = p
|
||||
|
||||
missing = total - len(paths)
|
||||
if missing:
|
||||
logger.warning("Не загружено страниц: {}", missing)
|
||||
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())]
|
||||
|
||||
|
||||
|
||||
|
||||
215
src/state.py
215
src/state.py
@@ -13,10 +13,31 @@ DB_PATH = Path("/app/state/progress.db")
|
||||
class StateDB:
|
||||
def __init__(self, db_path: Path = DB_PATH):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.conn = sqlite3.connect(str(db_path))
|
||||
self.conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self._init()
|
||||
|
||||
def _init(self):
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mangas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT UNIQUE,
|
||||
title TEXT,
|
||||
title_ru TEXT,
|
||||
title_full TEXT,
|
||||
pub_status TEXT DEFAULT 'unknown',
|
||||
auto_update INTEGER DEFAULT 0,
|
||||
last_checked_at TEXT,
|
||||
status TEXT DEFAULT 'queued',
|
||||
format TEXT DEFAULT 'cbz',
|
||||
chapters_total INTEGER DEFAULT 0,
|
||||
chapters_done INTEGER DEFAULT 0,
|
||||
added_at TEXT,
|
||||
updated_at TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
)
|
||||
""")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -26,14 +47,137 @@ class StateDB:
|
||||
number REAL,
|
||||
volume INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
pages_total INTEGER DEFAULT 0,
|
||||
pages_done INTEGER DEFAULT 0,
|
||||
output_cbz TEXT,
|
||||
output_pdf TEXT,
|
||||
output_epub TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
manga_url TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
chapter_url TEXT,
|
||||
chapter_title TEXT,
|
||||
chapter_number REAL,
|
||||
volume INTEGER,
|
||||
details TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
""")
|
||||
# Migrate old DB: add missing columns
|
||||
migrations = [
|
||||
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
||||
("chapters", "pages_done", "INTEGER DEFAULT 0"),
|
||||
("mangas", "title_ru", "TEXT"),
|
||||
("mangas", "title_full", "TEXT"),
|
||||
("mangas", "pub_status", "TEXT DEFAULT 'unknown'"),
|
||||
("mangas", "auto_update", "INTEGER DEFAULT 0"),
|
||||
("mangas", "last_checked_at", "TEXT"),
|
||||
("mangas", "started_at", "TEXT"),
|
||||
("mangas", "finished_at", "TEXT"),
|
||||
]
|
||||
for table, col, typedef in migrations:
|
||||
try:
|
||||
self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||
except Exception:
|
||||
pass
|
||||
self.conn.commit()
|
||||
|
||||
# ── Mangas ────────────────────────────────────
|
||||
|
||||
def add_manga(self, url: str, fmt: str = "cbz") -> 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()))
|
||||
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"):
|
||||
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))
|
||||
self.conn.commit()
|
||||
|
||||
def set_auto_update(self, url: str, enabled: bool):
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET auto_update=?, updated_at=? WHERE url=?
|
||||
""", (1 if enabled else 0, _now(), url))
|
||||
self.conn.commit()
|
||||
|
||||
def set_last_checked(self, url: str):
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET last_checked_at=?, updated_at=? WHERE url=?
|
||||
""", (_now(), _now(), url))
|
||||
self.conn.commit()
|
||||
|
||||
def update_manga_status(self, url: str, status: str):
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET status=?, updated_at=? WHERE url=?
|
||||
""", (status, _now(), url))
|
||||
self.conn.commit()
|
||||
|
||||
def mark_started(self, url: str) -> str:
|
||||
"""Записывает время начала загрузки. Возвращает timestamp."""
|
||||
ts = _now()
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET started_at=?, finished_at=NULL, updated_at=? WHERE url=?
|
||||
""", (ts, ts, url))
|
||||
self.conn.commit()
|
||||
return ts
|
||||
|
||||
def mark_finished(self, url: str) -> str:
|
||||
"""Записывает время окончания загрузки. Возвращает timestamp."""
|
||||
ts = _now()
|
||||
self.conn.execute("""
|
||||
UPDATE mangas SET finished_at=?, updated_at=? WHERE url=?
|
||||
""", (ts, ts, url))
|
||||
self.conn.commit()
|
||||
return ts
|
||||
|
||||
def sync_chapters_done(self, url: str):
|
||||
"""Синхронизирует chapters_done из реального счёта таблицы chapters."""
|
||||
count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", (url,)
|
||||
).fetchone()[0]
|
||||
self.conn.execute(
|
||||
"UPDATE mangas SET chapters_done=?, updated_at=? WHERE url=?",
|
||||
(count, _now(), url)
|
||||
)
|
||||
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,))
|
||||
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")
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def get_manga_format(self, url: str) -> str:
|
||||
cur = self.conn.execute("SELECT format FROM mangas WHERE url=?", (url,))
|
||||
row = cur.fetchone()
|
||||
return row["format"] if row else "cbz"
|
||||
|
||||
# ── Chapters ──────────────────────────────────
|
||||
|
||||
def upsert_chapter(self, manga_url: str, chapter_url: str,
|
||||
title: str = "", number: float = 0, volume: int = 0):
|
||||
self.conn.execute("""
|
||||
@@ -46,6 +190,14 @@ class StateDB:
|
||||
""", (manga_url, chapter_url, title, number, volume, _now()))
|
||||
self.conn.commit()
|
||||
|
||||
def reset_chapter(self, chapter_url: str):
|
||||
self.conn.execute("""
|
||||
UPDATE chapters SET status='pending', pages_total=0, pages_done=0,
|
||||
output_cbz=NULL, output_pdf=NULL, output_epub=NULL, updated_at=?
|
||||
WHERE chapter_url=?
|
||||
""", (_now(), chapter_url))
|
||||
self.conn.commit()
|
||||
|
||||
def mark_done(self, chapter_url: str, fmt: str, output_path: str):
|
||||
col = f"output_{fmt}"
|
||||
self.conn.execute(f"""
|
||||
@@ -60,6 +212,12 @@ class StateDB:
|
||||
""", (_now(), chapter_url))
|
||||
self.conn.commit()
|
||||
|
||||
def update_chapter_pages(self, chapter_url: str, pages_total: int, pages_done: int):
|
||||
self.conn.execute("""
|
||||
UPDATE chapters SET pages_total=?, pages_done=?, updated_at=? WHERE chapter_url=?
|
||||
""", (pages_total, pages_done, _now(), chapter_url))
|
||||
self.conn.commit()
|
||||
|
||||
def get_pending(self, manga_url: str) -> list[dict]:
|
||||
cur = self.conn.execute("""
|
||||
SELECT chapter_url, title, number, volume
|
||||
@@ -67,21 +225,64 @@ class StateDB:
|
||||
WHERE manga_url=? AND status != 'done'
|
||||
ORDER BY volume, number
|
||||
""", (manga_url,))
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def get_all(self, manga_url: str) -> list[dict]:
|
||||
def get_all_chapters(self, manga_url: str) -> list[dict]:
|
||||
cur = self.conn.execute("""
|
||||
SELECT * FROM chapters WHERE manga_url=? ORDER BY volume, number
|
||||
""", (manga_url,))
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def chapter_status(self, chapter_url: str) -> Optional[str]:
|
||||
cur = self.conn.execute(
|
||||
"SELECT status FROM chapters WHERE chapter_url=?", (chapter_url,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
return row["status"] if row else None
|
||||
|
||||
def get_all(self, manga_url: str) -> list[dict]:
|
||||
return self.get_all_chapters(manga_url)
|
||||
|
||||
# ── History ───────────────────────────────────
|
||||
|
||||
def add_history(self, manga_url: str, event_type: str,
|
||||
chapter_url: str = "", chapter_title: str = "",
|
||||
chapter_number: float = 0, volume: int = 0,
|
||||
details: str = ""):
|
||||
"""
|
||||
event_type: downloaded | auto_downloaded | new_chapter_found |
|
||||
check_started | check_done
|
||||
"""
|
||||
self.conn.execute("""
|
||||
INSERT INTO history
|
||||
(manga_url, event_type, chapter_url, chapter_title, chapter_number,
|
||||
volume, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (manga_url, event_type, chapter_url, chapter_title, chapter_number,
|
||||
volume, details, _now()))
|
||||
self.conn.commit()
|
||||
|
||||
def get_history(self, limit: int = 200, manga_url: str = "") -> list[dict]:
|
||||
if manga_url:
|
||||
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.manga_url=? ORDER BY h.created_at DESC LIMIT ?
|
||||
""", (manga_url, limit))
|
||||
else:
|
||||
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
|
||||
ORDER BY h.created_at DESC LIMIT ?
|
||||
""", (limit,))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def get_autos(self) -> list[dict]:
|
||||
"""Манги с включённым авто-обновлением."""
|
||||
cur = self.conn.execute("""
|
||||
SELECT * FROM mangas
|
||||
WHERE auto_update=1 AND status NOT IN ('downloading')
|
||||
""")
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
380
src/worker.py
Normal file
380
src/worker.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Воркер скачивания манги с поддержкой событий прогресса.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
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 .exporter import export, MangaMeta
|
||||
from .state import StateDB
|
||||
|
||||
OUTPUT_DIR = Path("/app/output")
|
||||
|
||||
# Читаем из переменных окружения; можно переопределить в docker-compose
|
||||
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",
|
||||
output_dir: Path = OUTPUT_DIR,
|
||||
resume: bool = True,
|
||||
is_update: bool = False,
|
||||
on_event: Optional[Callable] = None,
|
||||
chapter_concurrency: int = CHAPTER_CONCURRENCY,
|
||||
):
|
||||
"""Скачать мангу. Главы обрабатываются параллельно (chapter_concurrency штук)."""
|
||||
|
||||
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() # защита от параллельных записей в SQLite
|
||||
|
||||
async def db_call(fn, *args, **kwargs):
|
||||
"""Обёртка: все обращения к db идут через общий asyncio.Lock."""
|
||||
async with db_lock:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
try:
|
||||
await db_call(db.update_manga_status, url, "downloading")
|
||||
started_ts = await db_call(db.mark_started, url)
|
||||
await emit({"type": "manga_start", "url": url, "started_at": started_ts})
|
||||
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
ctx, info_page = await bm.new_page()
|
||||
|
||||
manga = await get_manga_info(info_page, url)
|
||||
await info_page.close()
|
||||
|
||||
if not manga:
|
||||
await db_call(db.update_manga_status, url, "failed")
|
||||
await emit({"type": "manga_failed", "url": url,
|
||||
"error": "Не удалось получить информацию о манге"})
|
||||
return
|
||||
|
||||
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,
|
||||
)
|
||||
await emit({
|
||||
"type": "manga_info",
|
||||
"url": url,
|
||||
"title": manga.title_ru or manga.title,
|
||||
"title_ru": manga.title_ru,
|
||||
"title_full": manga.title_full,
|
||||
"pub_status": manga.pub_status,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
folder_name = _safe_name(manga.title_ru or manga.title)
|
||||
manga_dir = output_dir / folder_name
|
||||
manga_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for ch in manga.chapters:
|
||||
await db_call(db.upsert_chapter, url, ch.url, ch.title, ch.number, ch.volume)
|
||||
|
||||
formats = ["cbz", "pdf", "epub"] if fmt == "all" else [fmt]
|
||||
|
||||
# ── Разделяем главы: пропустить / скачать ────────────────────
|
||||
to_skip = []
|
||||
to_download = []
|
||||
for ch in manga.chapters:
|
||||
if resume and (await db_call(db.chapter_status, ch.url)) == "done":
|
||||
to_skip.append(ch)
|
||||
else:
|
||||
to_download.append(ch)
|
||||
|
||||
# Счётчик и блокировка для безопасного обновления из параллельных задач
|
||||
counter_lock = asyncio.Lock()
|
||||
# Начинаем с 0: to_skip-цикл сам доберёт до len(to_skip),
|
||||
# иначе sync_chapters_done() + len(to_skip) = двойной счёт
|
||||
chapters_done = 0
|
||||
|
||||
# Сообщаем о пропущенных главах (уже скачаны)
|
||||
for ch in to_skip:
|
||||
chapters_done += 1
|
||||
await emit({
|
||||
"type": "chapter_skipped",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"chapter_number": ch.number,
|
||||
"chapter_title": ch.title,
|
||||
"volume": ch.volume,
|
||||
"chapters_done": chapters_done,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Параллельность: {} гл одновременно. Пропущено: {}, скачать: {}",
|
||||
chapter_concurrency, len(to_skip), len(to_download),
|
||||
)
|
||||
|
||||
# ── Семафор ограничивает одновременно открытые страницы ───────
|
||||
sem = asyncio.Semaphore(chapter_concurrency)
|
||||
|
||||
async def process_chapter(ch: Chapter) -> None:
|
||||
nonlocal chapters_done
|
||||
async with sem:
|
||||
# Повторная проверка (другая горутина могла скачать)
|
||||
if (await db_call(db.chapter_status, ch.url)) == "done":
|
||||
async with counter_lock:
|
||||
chapters_done += 1
|
||||
done_snap = chapters_done
|
||||
await emit({
|
||||
"type": "chapter_skipped",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"chapter_number": ch.number,
|
||||
"chapter_title": ch.title,
|
||||
"volume": ch.volume,
|
||||
"chapters_done": done_snap,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
return
|
||||
|
||||
await emit({
|
||||
"type": "chapter_start",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"chapter_title": ch.title,
|
||||
"chapter_number": ch.number,
|
||||
"volume": ch.volume,
|
||||
"chapters_done": chapters_done,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
ch_page = await ctx.new_page()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pages_done_count = [0]
|
||||
|
||||
async def on_page(page_idx: int, pages_total: int):
|
||||
pages_done_count[0] += 1
|
||||
await db_call(db.update_chapter_pages,
|
||||
ch.url, pages_total, pages_done_count[0])
|
||||
await emit({
|
||||
"type": "page_done",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"page_idx": page_idx,
|
||||
"pages_done": pages_done_count[0],
|
||||
"pages_total": pages_total,
|
||||
})
|
||||
|
||||
image_paths = await get_chapter_images_and_download(
|
||||
ch_page, ch.url,
|
||||
dest_dir=tmp_path,
|
||||
manga_url=url,
|
||||
on_page=on_page,
|
||||
)
|
||||
|
||||
if not image_paths:
|
||||
logger.error(
|
||||
"Т{} Гл.{} '{}' — get_chapter_images вернул пустой список. "
|
||||
"URL: {}",
|
||||
ch.volume, ch.number, ch.title, ch.url,
|
||||
)
|
||||
await db_call(db.mark_failed, ch.url)
|
||||
await emit({"type": "chapter_failed", "url": url,
|
||||
"chapter_url": ch.url})
|
||||
return
|
||||
|
||||
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=url,
|
||||
summary=manga.description,
|
||||
genre=", ".join(manga.genres) if manga.genres else "",
|
||||
)
|
||||
for f in formats:
|
||||
out_file = manga_dir / f"{ch_name}.{f}"
|
||||
try:
|
||||
export(image_paths, out_file, f, meta=ch_meta)
|
||||
await db_call(db.mark_done, ch.url, f, str(out_file))
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Ошибка экспорта Т{} Гл.{} → {} | {}: {}",
|
||||
ch.volume, ch.number, f, out_file.name, e,
|
||||
)
|
||||
|
||||
event_type = "auto_downloaded" if is_update else "downloaded"
|
||||
await db_call(
|
||||
db.add_history,
|
||||
manga_url=url,
|
||||
event_type=event_type,
|
||||
chapter_url=ch.url,
|
||||
chapter_title=ch.title,
|
||||
chapter_number=ch.number,
|
||||
volume=ch.volume,
|
||||
)
|
||||
|
||||
async with counter_lock:
|
||||
chapters_done += 1
|
||||
done_snap = chapters_done
|
||||
|
||||
await emit({
|
||||
"type": "chapter_done",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"chapter_title": ch.title,
|
||||
"chapter_number": ch.number,
|
||||
"volume": ch.volume,
|
||||
"chapters_done": done_snap,
|
||||
"chapters_total": len(manga.chapters),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Необработанное исключение в Т{} Гл.{} '{}' | {}: {}",
|
||||
ch.volume, ch.number, ch.title, ch.url, e,
|
||||
)
|
||||
await db_call(db.mark_failed, ch.url)
|
||||
await emit({"type": "chapter_failed", "url": url,
|
||||
"chapter_url": ch.url, "error": str(e)})
|
||||
finally:
|
||||
await ch_page.close()
|
||||
|
||||
# ── Запускаем все задачи сразу; семафор дозирует параллельность ──
|
||||
tasks = [process_chapter(ch) for ch in to_download]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Логируем неожиданные исключения из gather
|
||||
for ch, res in zip(to_download, results):
|
||||
if isinstance(res, Exception) and not isinstance(res, asyncio.CancelledError):
|
||||
logger.exception(
|
||||
"gather: необработанное исключение Т{} Гл.{} '{}': {}",
|
||||
ch.volume, ch.number, ch.title, res,
|
||||
)
|
||||
|
||||
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)
|
||||
await db_call(db.set_last_checked, url)
|
||||
await emit({
|
||||
"type": "manga_done",
|
||||
"url": url,
|
||||
"chapters_done": real_done,
|
||||
"chapters_total": len(manga.chapters),
|
||||
"finished_at": finished_ts,
|
||||
})
|
||||
await ctx.close()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Manga worker error {}: {}", url, e)
|
||||
await db_call(db.update_manga_status, url, "failed")
|
||||
finished_ts = await db_call(db.mark_finished, url)
|
||||
await emit({"type": "manga_failed", "url": url, "error": str(e), "finished_at": finished_ts})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def check_for_updates(
|
||||
url: str,
|
||||
on_event: Optional[Callable] = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Проверяет наличие новых глав для манги.
|
||||
Возвращает список новых chapter_url.
|
||||
"""
|
||||
async def emit(event: dict):
|
||||
if on_event:
|
||||
try:
|
||||
await on_event(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db = StateDB()
|
||||
try:
|
||||
db.set_last_checked(url)
|
||||
db.add_history(manga_url=url, event_type="check_started")
|
||||
await emit({"type": "check_started", "url": url})
|
||||
|
||||
async with BrowserManager(headless=True) as bm:
|
||||
_, page = await bm.new_page()
|
||||
manga = await get_manga_info(page, url)
|
||||
await page.close()
|
||||
if not manga:
|
||||
return []
|
||||
|
||||
# Обновляем pub_status и количество глав
|
||||
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,
|
||||
)
|
||||
|
||||
# Находим главы которых ещё нет в БД
|
||||
known = {ch["chapter_url"] for ch in 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(
|
||||
manga_url=url,
|
||||
event_type="new_chapter_found",
|
||||
chapter_url=ch.url,
|
||||
chapter_title=ch.title,
|
||||
chapter_number=ch.number,
|
||||
volume=ch.volume,
|
||||
)
|
||||
await emit({
|
||||
"type": "new_chapter_found",
|
||||
"url": url,
|
||||
"chapter_url": ch.url,
|
||||
"chapter_title": ch.title,
|
||||
"chapter_number": ch.number,
|
||||
})
|
||||
|
||||
db.add_history(
|
||||
manga_url=url,
|
||||
event_type="check_done",
|
||||
details=f"Найдено новых: {len(new_chapters)}",
|
||||
)
|
||||
await emit({
|
||||
"type": "check_done",
|
||||
"url": url,
|
||||
"new_chapters": len(new_chapters),
|
||||
})
|
||||
|
||||
return [ch.url for ch in new_chapters]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user