This commit is contained in:
2026-04-29 02:07:21 +03:00
parent ba6bfc5ed3
commit 0aa057c991
14 changed files with 4257 additions and 139 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
.idea
/output/
/state/

658
ARCHITECTURE.md Normal file
View 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` восстанавливает незавершённые задачи из БД в очередь.

View File

@@ -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
View File

@@ -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
View 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()

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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")

View File

@@ -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__":

View File

@@ -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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
# ── 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

View File

@@ -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())]

View File

@@ -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
View 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()