Base app
This commit is contained in:
658
ARCHITECTURE.md
Normal file
658
ARCHITECTURE.md
Normal file
@@ -0,0 +1,658 @@
|
||||
# Manga Downloader — Архитектура и устройство проекта
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Общее описание](#1-общее-описание)
|
||||
2. [Структура файлов](#2-структура-файлов)
|
||||
3. [Стек технологий](#3-стек-технологий)
|
||||
4. [Схема архитектуры](#4-схема-архитектуры)
|
||||
5. [Модули бэкенда](#5-модули-бэкенда)
|
||||
- [browser.py](#browserpy)
|
||||
- [scraper.py](#scraperpy)
|
||||
- [exporter.py](#exporterpy)
|
||||
- [state.py](#statepy)
|
||||
- [worker.py](#workerpy)
|
||||
- [api.py](#apipy)
|
||||
- [cli.py](#clipy)
|
||||
6. [База данных](#6-база-данных)
|
||||
7. [REST API](#7-rest-api)
|
||||
8. [WebSocket протокол](#8-websocket-протокол)
|
||||
9. [Фронтенд](#9-фронтенд)
|
||||
10. [Жизненный цикл загрузки манги](#10-жизненный-цикл-загрузки-манги)
|
||||
11. [Параллельная загрузка](#11-параллельная-загрузка)
|
||||
12. [Конфигурация](#12-конфигурация)
|
||||
13. [Docker-инфраструктура](#13-docker-инфраструктура)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общее описание
|
||||
|
||||
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
|
||||
|
||||
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket.
|
||||
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура файлов
|
||||
|
||||
```
|
||||
manga/
|
||||
├── src/ # Весь бэкенд-код (Python-пакет)
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # FastAPI-приложение, REST + WebSocket
|
||||
│ ├── browser.py # Обёртка над Playwright/Chromium
|
||||
│ ├── cli.py # CLI-команды (click)
|
||||
│ ├── downloader.py # (legacy, не используется в web-режиме)
|
||||
│ ├── exporter.py # Экспорт CBZ / PDF / EPUB
|
||||
│ ├── scraper.py # Парсер страниц readmanga.ru
|
||||
│ ├── state.py # SQLite ORM (StateDB)
|
||||
│ └── worker.py # Асинхронный воркер загрузки
|
||||
│
|
||||
├── frontend/
|
||||
│ └── index.html # SPA — весь фронтенд в одном файле
|
||||
│
|
||||
├── output/ # Смонтированная папка с CBZ/PDF/EPUB
|
||||
│ └── <Название манги>/
|
||||
│ ├── v01_ch0001.0.cbz
|
||||
│ └── ...
|
||||
│
|
||||
├── state/ # Смонтированная папка с состоянием
|
||||
│ ├── progress.db # SQLite база данных
|
||||
│ └── manga.log # Логи (ротация по 10 МБ)
|
||||
│
|
||||
├── Dockerfile # Сборка образа
|
||||
├── docker-compose.yml # Конфигурация сервисов
|
||||
├── requirements.txt # Python-зависимости
|
||||
├── debug_site.py # Утилита отладки (скриншот страницы)
|
||||
├── debug_cdn.py # Утилита отладки CDN-запросов
|
||||
├── analyze_speed.py # Анализ скорости загрузки
|
||||
├── README.md # Краткое руководство пользователя
|
||||
└── ARCHITECTURE.md # Этот файл
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Стек технологий
|
||||
|
||||
| Компонент | Библиотека | Назначение |
|
||||
|-----------|------------|------------|
|
||||
| Браузер | `playwright==1.44.0` (Chromium) | Открытие защищённых JS-страниц |
|
||||
| Web-фреймворк | `fastapi==0.111.0` + `uvicorn` | REST API и WebSocket |
|
||||
| БД | SQLite (stdlib `sqlite3`) | Хранение состояния |
|
||||
| CLI | `click==8.1.7` | Консольные команды |
|
||||
| Изображения | `Pillow==10.3.0` | Открытие/конвертация для PDF |
|
||||
| PDF | `img2pdf==0.5.1` (fallback: Pillow) | Склейка изображений в PDF |
|
||||
| EPUB | `ebooklib==0.18` | Сборка EPUB3-файла |
|
||||
| Логирование | `loguru==0.7.2` | Удобные форматированные логи |
|
||||
| Фронтенд | Tailwind CSS (CDN) + Vanilla JS | SPA без сборщика |
|
||||
|
||||
---
|
||||
|
||||
## 4. Схема архитектуры
|
||||
|
||||
```
|
||||
Браузер пользователя
|
||||
│
|
||||
│ HTTP / WebSocket (:8000)
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ FastAPI (api.py) │
|
||||
│ │
|
||||
│ REST endpoints WebSocket /ws │
|
||||
│ │ │ │
|
||||
│ asyncio.Queue ws_manager │
|
||||
│ │ (broadcast) │
|
||||
│ ▼ ▲ │
|
||||
│ queue_worker() │ │
|
||||
│ │ events │
|
||||
│ ▼ │ │
|
||||
│ download_manga() ────┘ │ ◄── worker.py
|
||||
│ │ │
|
||||
└───────┼─────────────────────────┘
|
||||
│
|
||||
├─► BrowserManager (browser.py)
|
||||
│ └─► Playwright Chromium
|
||||
│ │
|
||||
│ ┌───────────┴──────────┐
|
||||
│ ▼ ▼
|
||||
│ get_manga_info() get_chapter_images_and_download()
|
||||
│ (scraper.py) (scraper.py)
|
||||
│
|
||||
├─► export() (exporter.py) → CBZ / PDF / EPUB
|
||||
│
|
||||
└─► StateDB (state.py) → progress.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Модули бэкенда
|
||||
|
||||
### browser.py
|
||||
|
||||
**Отвечает за:** запуск и управление Playwright Chromium.
|
||||
|
||||
**Ключевые детали:**
|
||||
|
||||
- `BrowserManager` — контекстный менеджер (`async with BrowserManager() as bm`), запускает/останавливает браузер.
|
||||
- `new_page()` — создаёт новый `BrowserContext` + `Page`. Каждый контекст независим (отдельные куки, заголовки).
|
||||
- Браузер запускается с антидетект-настройками:
|
||||
- `--disable-blink-features=AutomationControlled`
|
||||
- JavaScript-патч `STEALTH_JS`: скрывает `navigator.webdriver`, подставляет плагины и языки.
|
||||
- Реалистичный `User-Agent` (Chrome 124 Linux).
|
||||
- Заголовки `Accept-Language: ru-RU`, `Referer: https://3.readmanga.ru/` — сервер возвращает 404 без Referer.
|
||||
- `shm_size: 2gb` в Docker — Chromium требует shared memory для рендеринга.
|
||||
|
||||
---
|
||||
|
||||
### scraper.py
|
||||
|
||||
**Отвечает за:** парсинг страниц манги и скачивание изображений глав.
|
||||
|
||||
#### Модели данных
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Chapter:
|
||||
title: str # "Том 1, Глава 5"
|
||||
url: str # полный URL главы
|
||||
number: float # 5.0 (из data-num / 10)
|
||||
volume: int # 1
|
||||
|
||||
@dataclass
|
||||
class MangaInfo:
|
||||
title: str # русский тайтл (для имени папки)
|
||||
url: str
|
||||
chapters: list[Chapter]
|
||||
pub_status: str # "completed" / "ongoing" / "unknown"
|
||||
title_ru: str # чистый русский тайтл
|
||||
title_full: str # полный тайтл со страницы
|
||||
```
|
||||
|
||||
#### `get_manga_info(page, url)`
|
||||
|
||||
1. Открывает страницу манги через `_navigate()` (3 попытки, задержка 3×N сек).
|
||||
2. Извлекает тайтл: сначала из DOM (`.names .name`), fallback — парсинг `<title>` (убирает «Манга», «онлайн», английские названия в скобках).
|
||||
3. Извлекает `pub_status` по тексту `.elem_status .value` (ищет «завершён» / «продолжается»).
|
||||
4. Раскрывает полный список глав кнопкой «Все главы» (`_expand_chapters`).
|
||||
5. Парсит главы из `#chapters-list tr.item-row` → `td[data-num]` (num/10 = номер главы, `data-vol` = том). Fallback: `<a href*="/vol">`.
|
||||
|
||||
#### `get_chapter_images_and_download(page, chapter_url, dest_dir, ...)`
|
||||
|
||||
Самая сложная функция — обходит CDN-защиту изображений:
|
||||
|
||||
1. Открывает главу с параметром `?mtr=1` (флаг согласия 18+).
|
||||
2. Устанавливает `page.route("**/*", route_handler)` — перехватывает все сетевые запросы.
|
||||
3. Ждёт `readerInit` в JS-коде страницы — там содержится массив URL всех изображений.
|
||||
4. Извлекает URL из `readerInit` через JS-eval (`_extract_images_from_js`). Fallback: ищет `img.manga-page` в DOM.
|
||||
5. Перехватывает img-запросы через `route.fetch()` — запрос идёт браузерным стеком (правильные `Sec-Fetch-*`, cookies DDoS-Guard). Сохраняет байты в памяти.
|
||||
6. Листает читалку клавишей `ArrowRight` — читалка подгружает страницы лениво.
|
||||
7. Матчит перехваченные URL с индексами из `readerInit` (основной путь + fallback по имени файла).
|
||||
8. Сохраняет в `dest_dir/0000.jpg`, `0001.png`, ...
|
||||
|
||||
**CDN-фильтр:** принимает только запросы к `one-way.work`, `staticfa.`, `cdnmanga`, `reimg`. Отклоняет статику сайта (`resrmr.`, `/static/`).
|
||||
|
||||
---
|
||||
|
||||
### exporter.py
|
||||
|
||||
**Отвечает за:** сборку файлов из набора изображений с встраиванием метаданных.
|
||||
|
||||
#### `MangaMeta` — датакласс метаданных
|
||||
|
||||
Передаётся в `export()` из воркера. Поля:
|
||||
|
||||
| Поле | Описание |
|
||||
|------|---------|
|
||||
| `series` | Название серии (title_ru) |
|
||||
| `series_full` | Полное название со страницы (title_full) |
|
||||
| `chapter_title` | Название главы |
|
||||
| `number` | Номер главы (float) |
|
||||
| `volume` | Том |
|
||||
| `chapters_total` | Всего глав (для completed — записывается в Count) |
|
||||
| `pub_status` | `completed` / `ongoing` / `unknown` |
|
||||
| `source_url` | URL источника |
|
||||
| `language` | `ru` |
|
||||
|
||||
#### Форматы и метаданные
|
||||
|
||||
| Формат | Реализация | Метаданные |
|
||||
|--------|------------|-----------|
|
||||
| **CBZ** | `zipfile` + `ComicInfo.xml` первым файлом | Anansi v2 schema: `Series`, `Number`, `Volume`, `Count` (если completed), `LanguageISO`, `Manga=YesAndRightToLeft`, `Web` |
|
||||
| **PDF** | `img2pdf` (fallback: Pillow) + `pypdf` post-processing | PDF `/Info` (`/Title`, `/Subject`) + XMP Dublin Core (`dc:title`, `dc:description`, `dc:language`, `dc:source`) |
|
||||
| **EPUB** | `ebooklib` | Dublin Core, `calibre:series` + `calibre:series_index`, EPUB3 `belongs-to-collection` + `group-position` |
|
||||
|
||||
`_patch_pdf_meta()` открывает готовый PDF через `pypdf`, добавляет метаданные и перезаписывает файл. Если `pypdf` не установлен — молча пропускает (graceful degradation).
|
||||
|
||||
---
|
||||
|
||||
### state.py
|
||||
|
||||
**Отвечает за:** всё взаимодействие с SQLite БД.
|
||||
|
||||
#### Класс `StateDB`
|
||||
|
||||
Подключение: `sqlite3.connect(path, check_same_thread=False)`.
|
||||
|
||||
#### Таблицы
|
||||
|
||||
**`mangas`** — одна строка на мангу:
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|---------|
|
||||
| `url` | TEXT UNIQUE | URL страницы манги |
|
||||
| `title` / `title_ru` / `title_full` | TEXT | Варианты названия |
|
||||
| `pub_status` | TEXT | `completed` / `ongoing` / `unknown` |
|
||||
| `auto_update` | INTEGER | 0/1 — включено ли авто-обновление |
|
||||
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
|
||||
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
|
||||
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
|
||||
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
|
||||
| `last_checked_at` | TEXT | Время последней проверки новых глав |
|
||||
|
||||
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
|
||||
|
||||
**`chapters`** — одна строка на главу:
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|---------|
|
||||
| `chapter_url` | TEXT UNIQUE | URL главы |
|
||||
| `manga_url` | TEXT | FK → mangas.url |
|
||||
| `number` | REAL | Номер главы (5.0) |
|
||||
| `volume` | INTEGER | Том |
|
||||
| `status` | TEXT | `pending` / `done` / `failed` |
|
||||
| `pages_total` / `pages_done` | INTEGER | Прогресс страниц |
|
||||
| `output_cbz` / `output_pdf` / `output_epub` | TEXT | Путь к файлу |
|
||||
|
||||
**`history`** — лог событий:
|
||||
|
||||
| `event_type` | Описание |
|
||||
|---|---|
|
||||
| `downloaded` | Глава успешно скачана |
|
||||
| `auto_downloaded` | Скачана в режиме авто-обновления |
|
||||
| `new_chapter_found` | Найдена новая глава при проверке |
|
||||
| `check_started` / `check_done` | Начало/конец проверки обновлений |
|
||||
|
||||
#### Ключевые методы
|
||||
|
||||
- `add_manga(url, fmt)` → `bool` — добавляет, возвращает `False` если уже есть.
|
||||
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
|
||||
- `chapter_status(chapter_url)` → `str | None`.
|
||||
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число.
|
||||
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
|
||||
|
||||
---
|
||||
|
||||
### worker.py
|
||||
|
||||
**Отвечает за:** асинхронное скачивание манги с параллельными главами.
|
||||
|
||||
#### `download_manga(url, fmt, resume, is_update, on_event, chapter_concurrency)`
|
||||
|
||||
Полный цикл скачивания одной манги:
|
||||
|
||||
```
|
||||
1. update_manga_status → "downloading"
|
||||
2. BrowserManager.__aenter__
|
||||
3. get_manga_info() → MangaInfo
|
||||
4. update_manga_info() в БД
|
||||
5. upsert_chapter() для каждой главы
|
||||
6. Делим главы:
|
||||
├── to_skip (status == "done" и resume=True)
|
||||
└── to_download (всё остальное)
|
||||
7. Отправляем chapter_skipped события для to_skip
|
||||
8. asyncio.Semaphore(chapter_concurrency)
|
||||
9. asyncio.gather(*[process_chapter(ch) for ch in to_download])
|
||||
10. sync_chapters_done() → update_manga_status → "done"
|
||||
```
|
||||
|
||||
#### `process_chapter(ch)` (внутренняя корутина)
|
||||
|
||||
```
|
||||
async with sem: # ограничение параллельности
|
||||
1. Повторная проверка chapter_status (race condition guard)
|
||||
2. ctx.new_page() — новая вкладка в общем контексте
|
||||
3. get_chapter_images_and_download() → list[Path]
|
||||
4. export() для каждого формата
|
||||
5. mark_done() / mark_failed()
|
||||
6. add_history()
|
||||
7. chapter_done event
|
||||
finally: ch_page.close()
|
||||
```
|
||||
|
||||
#### Потокобезопасность
|
||||
|
||||
`db_lock = asyncio.Lock()` — все обращения к SQLite через `await db_call(fn, *args)`. Это необходимо, так как несколько корутин работают одновременно, а `sqlite3` не является asyncio-safe.
|
||||
|
||||
`counter_lock = asyncio.Lock()` — атомарный инкремент счётчика `chapters_done` для корректных данных в событиях прогресса.
|
||||
|
||||
#### `check_for_updates(url, on_event)`
|
||||
|
||||
Проверяет новые главы: сравнивает список глав из scraper с известными в БД. Возвращает список новых `chapter_url`.
|
||||
|
||||
---
|
||||
|
||||
### api.py
|
||||
|
||||
**Отвечает за:** FastAPI-приложение — HTTP-сервер, очередь загрузок, планировщик обновлений.
|
||||
|
||||
#### Глобальное состояние
|
||||
|
||||
```python
|
||||
download_queue: asyncio.Queue # очередь job'ов {url, fmt, is_update}
|
||||
active_tasks: dict[str, asyncio.Task] # url → текущая Task загрузки
|
||||
ws_manager: ConnectionManager # set активных WebSocket-соединений
|
||||
```
|
||||
|
||||
#### Жизненный цикл при старте (`startup_event`)
|
||||
|
||||
1. Запускает `queue_worker()` как фоновую Task.
|
||||
2. Запускает `update_scheduler()` как фоновую Task.
|
||||
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь).
|
||||
|
||||
#### `queue_worker()`
|
||||
|
||||
Последовательно извлекает задачи из `download_queue`. На каждую создаёт `asyncio.Task` через `download_manga()`, сохраняет в `active_tasks`. Обрабатывает `CancelledError` (stop/prioritize).
|
||||
|
||||
#### `update_scheduler()`
|
||||
|
||||
Через 5 минут после старта, затем каждые `UPDATE_INTERVAL_HOURS` (по умолчанию 6 ч):
|
||||
- Вызывает `check_for_updates()` для каждой манги с `auto_update=1`.
|
||||
- При нахождении новых глав — добавляет задачу в очередь с флагом `is_update=True`.
|
||||
|
||||
#### `_enrich_manga(m, db)`
|
||||
|
||||
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
|
||||
- `chapters_done` — `COUNT(*)` из таблицы `chapters` (не стальная колонка).
|
||||
- `size_bytes` / `size_human` — размер папки на диске.
|
||||
- `is_active` — есть ли Task в `active_tasks`.
|
||||
- `errors_count` — сумма failed и partial глав.
|
||||
|
||||
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
|
||||
|
||||
---
|
||||
|
||||
### cli.py
|
||||
|
||||
**Отвечает за:** консольные команды для запуска без веб-интерфейса.
|
||||
|
||||
#### Команды
|
||||
|
||||
**`download <URL>`**
|
||||
|
||||
```
|
||||
--format / -f cbz|pdf|epub|all (default: cbz)
|
||||
--chapters / -c диапазон: "1-10", "5", "1,3,7"
|
||||
--output / -o папка вывода
|
||||
--resume пропускать скачанные (default: True)
|
||||
--force / -F игнорировать БД, скачать заново
|
||||
--concurrency параллельных загрузок (default: 4)
|
||||
--verbose / -v DEBUG-вывод
|
||||
```
|
||||
|
||||
**`analyze <URL>`**
|
||||
|
||||
Открывает страницу, выводит список всех глав и метаданные без скачивания.
|
||||
|
||||
CLI использует те же `BrowserManager`, `scraper`, `exporter`, `StateDB`, что и web-режим, но запускает их напрямую через `asyncio.run()`.
|
||||
|
||||
---
|
||||
|
||||
## 6. База данных
|
||||
|
||||
**Файл:** `/app/state/progress.db` (монтируется из `./state/progress.db` на хосте).
|
||||
|
||||
**Открытие:** `sqlite3.connect(path, check_same_thread=False)` — один объект `StateDB` на запрос/worker, не shared между потоками.
|
||||
|
||||
**Миграции:** при каждом запуске `StateDB._init()` выполняет `ALTER TABLE ... ADD COLUMN` в блоке `try/except` — безопасно добавляет новые колонки в старые БД.
|
||||
|
||||
**Важно:** колонка `mangas.chapters_done` является устаревшим денормализованным счётчиком. В API и WebSocket snapshot всегда используется динамический подсчёт из таблицы `chapters`. Это важно, чтобы не показывать некорректные числа (> chapters_total) после перезапусков.
|
||||
|
||||
---
|
||||
|
||||
## 7. REST API
|
||||
|
||||
Базовый URL: `http://localhost:8000`
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|---------|
|
||||
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
|
||||
| `GET` | `/api/mangas/detail?url=` | Детали: главы, файлы, статистика ошибок |
|
||||
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
|
||||
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
|
||||
| `POST` | `/api/mangas/resume?url=` | Возобновить |
|
||||
| `POST` | `/api/mangas/prioritize?url=` | Переместить в начало очереди (вытесняет текущую) |
|
||||
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
|
||||
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
|
||||
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
|
||||
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
|
||||
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
|
||||
| `GET` | `/api/history?limit=&manga_url=` | История событий |
|
||||
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
|
||||
| `WS` | `/ws` | WebSocket для realtime-обновлений |
|
||||
|
||||
---
|
||||
|
||||
## 8. WebSocket протокол
|
||||
|
||||
### Сервер → Клиент (события)
|
||||
|
||||
| `type` | Данные | Когда |
|
||||
|--------|--------|-------|
|
||||
| `snapshot` | `{mangas: [...]}` | При подключении — полный список с реальными счётчиками |
|
||||
| `manga_queued` | `{url, format}` | Добавлена в очередь |
|
||||
| `manga_start` | `{url}` | Начало загрузки |
|
||||
| `manga_info` | `{url, title, chapters_total, pub_status, ...}` | Получены метаданные |
|
||||
| `manga_done` | `{url, chapters_done, chapters_total}` | Загрузка завершена |
|
||||
| `manga_failed` | `{url, error}` | Ошибка |
|
||||
| `manga_stopped` | `{url}` | Остановлена |
|
||||
| `manga_prioritized` | `{url, preempted_url}` | Приоритет изменён |
|
||||
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
|
||||
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
|
||||
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
|
||||
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
|
||||
| `chapter_failed` | `{url, chapter_url, error?}` | Ошибка главы |
|
||||
| `page_done` | `{url, chapter_url, pages_done, pages_total}` | Страница скачана |
|
||||
| `check_started` / `check_done` | `{url, new_chapters?}` | Проверка обновлений |
|
||||
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
|
||||
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
|
||||
|
||||
### Клиент → Сервер
|
||||
|
||||
| Сообщение | Действие |
|
||||
|-----------|---------|
|
||||
| `"ping"` | Сервер отвечает `{"type": "pong"}` — keepalive |
|
||||
|
||||
---
|
||||
|
||||
## 9. Фронтенд
|
||||
|
||||
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1000 строк).
|
||||
|
||||
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
|
||||
|
||||
### Архитектура состояния
|
||||
|
||||
```javascript
|
||||
const state = {
|
||||
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
|
||||
chapters: {}, // url → массив глав (загружается по запросу в модалке)
|
||||
};
|
||||
```
|
||||
|
||||
### Поток данных
|
||||
|
||||
```
|
||||
DOMContentLoaded
|
||||
│
|
||||
├─ loadStats() ──────────────────────► GET /api/stats
|
||||
│
|
||||
├─ connectWS() ──────────────────────► WS /ws
|
||||
│ │
|
||||
│ └─ snapshot event ──────────► state.mangas = enriched list
|
||||
│ + live events ──────────► state.mangas[url].* обновляется
|
||||
│
|
||||
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
|
||||
(перезаписывает snapshot если пришёл раньше)
|
||||
```
|
||||
|
||||
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
|
||||
|
||||
### Вкладки
|
||||
|
||||
- **Манга** — список всех манг, добавление, управление.
|
||||
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
|
||||
- **История** — все события из таблицы `history`.
|
||||
|
||||
### Модальное окно детали
|
||||
|
||||
Открывается кликом на строку манги. Загружает `GET /api/mangas/detail?url=` с полным списком глав, файлами на диске, статистикой ошибок.
|
||||
|
||||
---
|
||||
|
||||
## 10. Жизненный цикл загрузки манги
|
||||
|
||||
```
|
||||
POST /api/queue {urls: ["https://..."]}
|
||||
│
|
||||
├── db.add_manga(url) → status="queued"
|
||||
├── download_queue.put({url, fmt})
|
||||
├── ws_manager.broadcast(manga_queued)
|
||||
└── asyncio.create_task(_fetch_preview(url)) ← быстрый предпросмотр
|
||||
│
|
||||
└── get_manga_info() → ws manga_preview (название, кол-во глав)
|
||||
|
||||
queue_worker() (фоновая Task)
|
||||
│
|
||||
└── job = await download_queue.get()
|
||||
│
|
||||
└── asyncio.create_task(download_manga(url, fmt, ...))
|
||||
│
|
||||
├── status → "downloading"
|
||||
├── get_manga_info() → ws manga_info
|
||||
├── upsert_chapter() × N
|
||||
│
|
||||
├── to_skip → ws chapter_skipped × M
|
||||
│
|
||||
└── asyncio.gather(
|
||||
process_chapter(ch1), ─┐
|
||||
process_chapter(ch2), ├── параллельно
|
||||
... ─┘
|
||||
limit: Semaphore(CHAPTER_CONCURRENCY)
|
||||
)
|
||||
│
|
||||
├── ctx.new_page()
|
||||
├── get_chapter_images_and_download()
|
||||
│ ├── page.route() перехват img
|
||||
│ ├── ArrowRight листание
|
||||
│ └── сохранение байт
|
||||
├── export() → .cbz/.pdf/.epub
|
||||
├── db.mark_done()
|
||||
└── ws chapter_done / chapter_failed
|
||||
|
||||
└── status → "done" / "failed"
|
||||
└── ws manga_done / manga_failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Параллельная загрузка
|
||||
|
||||
### Параллельность глав
|
||||
|
||||
`CHAPTER_CONCURRENCY` (env, default `3`) — сколько глав загружается одновременно.
|
||||
|
||||
```
|
||||
asyncio.Semaphore(CHAPTER_CONCURRENCY)
|
||||
|
||||
Все N глав запускаются сразу через asyncio.gather(),
|
||||
но одновременно в браузере открыто не более CHAPTER_CONCURRENCY вкладок.
|
||||
```
|
||||
|
||||
Все вкладки работают в **одном** `BrowserContext` — это важно: cookies DDoS-Guard получены при открытии страницы манги и автоматически применяются ко всем вкладкам контекста.
|
||||
|
||||
### Защита от race condition
|
||||
|
||||
1. **Повторная проверка статуса** внутри семафора: если пока ждали семафор другая горутина уже скачала эту главу — пропустить.
|
||||
2. **`db_lock`** — все SQLite-операции сериализованы через `asyncio.Lock()`. `sqlite3` не поддерживает concurrent writes.
|
||||
3. **`counter_lock`** — атомарный инкремент счётчика `chapters_done` для правильных данных в WS-событиях.
|
||||
|
||||
### Параллельность манг
|
||||
|
||||
Манги в очереди обрабатываются **последовательно** (один воркер). Параллельная загрузка нескольких манг одновременно не реализована, чтобы не перегружать сайт и не создавать проблем с памятью Chromium.
|
||||
|
||||
---
|
||||
|
||||
## 12. Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|------------|---------|---------|
|
||||
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
|
||||
| `UPDATE_INTERVAL_HOURS` | `6` | Интервал авто-проверки новых глав (часы) |
|
||||
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
|
||||
|
||||
### Пути (hardcoded в коде)
|
||||
|
||||
| Константа | Путь |
|
||||
|-----------|------|
|
||||
| `OUTPUT_DIR` | `/app/output` |
|
||||
| `FRONTEND_DIR` | `/app/frontend` |
|
||||
| `DB_PATH` | `/app/state/progress.db` |
|
||||
| Лог | `/app/state/manga.log` (ротация 10 МБ) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Docker-инфраструктура
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```
|
||||
FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
|
||||
└── Ubuntu 22.04 + Python + все системные зависимости для Chromium
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
|
||||
CMD uvicorn src.api:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./output:/app/output # CBZ/PDF/EPUB файлы
|
||||
- ./state:/app/state # БД и логи
|
||||
|
||||
ports:
|
||||
- "8000:8000" # Веб-интерфейс
|
||||
|
||||
shm_size: "2gb" # Chromium требует shared memory
|
||||
environment:
|
||||
- UPDATE_INTERVAL_HOURS=6
|
||||
|
||||
restart: unless-stopped # Автоперезапуск при падении
|
||||
```
|
||||
|
||||
### CLI-режим (через compose run)
|
||||
|
||||
```bash
|
||||
# Скачать мангу без веб-интерфейса
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli download https://3.readmanga.ru/magicheskaia_bitva --format cbz
|
||||
|
||||
# Анализ
|
||||
docker compose run --rm --entrypoint "" manga \
|
||||
python -m src.cli analyze https://3.readmanga.ru/magicheskaia_bitva
|
||||
```
|
||||
|
||||
### Хранение данных
|
||||
|
||||
После остановки контейнера все данные сохраняются на хосте:
|
||||
- `./output/` — скачанные файлы.
|
||||
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
|
||||
- `./state/manga.log` — логи.
|
||||
|
||||
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.
|
||||
|
||||
Reference in New Issue
Block a user