# 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 — парсинг `` (убирает «Манга», «онлайн», английские названия в скобках). 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` восстанавливает незавершённые задачи из БД в очередь.