Files
manga/ARCHITECTURE.md
2026-04-30 17:18:03 +03:00

31 KiB
Raw Blame History

Manga Downloader — Архитектура и устройство проекта

Содержание

  1. Общее описание
  2. Структура файлов
  3. Стек технологий
  4. Схема архитектуры
  5. Модули бэкенда
  6. База данных
  7. REST API
  8. WebSocket протокол
  9. Фронтенд
  10. Жизненный цикл загрузки манги
  11. Параллельная загрузка
  12. Конфигурация
  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

Отвечает за: парсинг страниц манги и скачивание изображений глав.

Модели данных

@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-rowtd[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 Время последней проверки новых глав
folder_name TEXT Кастомное имя папки на диске (NULL → вычисляется из title_ru)

¹ 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.
  • update_manga_meta_fields(url, title_ru, title_full) — обновляет пользовательские метаданные (название), не меняет папку.
  • set_folder_name(url, folder_name) — устанавливает кастомное имя папки.

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. Определение папки:
   ├── если db.get_manga(url)["folder_name"] задан → использует его
   └── иначе → _safe_name(title_ru or title)
7. Делим главы:
   ├── to_skip  (status == "done" и resume=True)
   └── to_download  (всё остальное)
8. Отправляем chapter_skipped события для to_skip
9. asyncio.Semaphore(chapter_concurrency)
10. asyncio.gather(*[process_chapter(ch) for ch in to_download])
11. sync_chapters_done() → update_manga_status → "done"

process_chapter(ch) (внутренняя корутина)

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-сервер, очередь загрузок, планировщик обновлений.

Глобальное состояние

download_queue: asyncio.Queue   # очередь job'ов {url, fmt, is_update}
active_tasks: dict[str, asyncio.Task]  # url → текущая Task загрузки
ws_manager: ConnectionManager   # set активных WebSocket-соединений

Вспомогательные функции

  • _safe_name(s) — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → _, max 80 символов).
  • _manga_folder(m) — возвращает Path к папке манги: если m["folder_name"] задан — использует его, иначе вычисляет из title через _safe_name(). Используется везде: _enrich_manga, _manga_detail, delete_manga, rename_folder.
  • _broadcast_queue_positions() — отправляет всем WS-клиентам событие queue_positions с актуальным словарём {url: позиция}. Вызывается после любого изменения очереди: старт/конец задачи в воркере, prioritize, stop, resume, add_to_queue, force_redownload.

Жизненный цикл при старте (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_doneCOUNT(*) из таблицы 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= Немедленно проверить новые главы
POST /api/mangas/update_meta Изменить title_ru/title_full и применить к метаданным файлов {url, title_ru, title_full}
POST /api/mangas/rename_folder Переименовать папку на диске и обновить пути в БД {url, folder_name}
POST /api/mangas/refresh_meta?url= Обновить метаданные в уже скачанных файлах
POST /api/mangas/force_redownload?url= Сбросить все главы и поставить в очередь заново
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, ...} Быстрый предпросмотр после добавления
manga_meta_updated {url, title, title_ru, title_full} Метаданные отредактированы пользователем
manga_folder_renamed {url, folder_name} Папка переименована
queue_positions {positions: {url: номер}} Актуальные позиции в очереди — отправляется при любом изменении очереди
chapter_start {url, chapter_url, chapter_number, chapters_done, chapters_total} Начало главы
chapter_done {url, chapter_url, chapters_done, chapters_total} Глава готова
chapter_skipped {url, chapter_url, chapters_done} Глава пропущена (уже скачана)
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} Изменён флаг авто-обновления
meta_refreshed {url, updated, failed} Метаданные файлов обновлены

Клиент → Сервер

Сообщение Действие
"ping" Сервер отвечает {"type": "pong"} — keepalive

9. Фронтенд

Файл: frontend/index.html — весь фронтенд в одном HTML-файле (~1500 строк).

Стек: Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).

Архитектура состояния

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= с полным списком глав, файлами на диске, статистикой ошибок. Содержит кнопки:

  • ✏️ Редактировать название — открывает модалку изменения title_ru / title_full. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через _do_refresh_meta.
  • 📁 Переименовать папку — открывает модалку смены имени папки на диске (доступна при статусе не downloading). Физически переименовывает папку, обновляет пути в chapters.output_*, сохраняет folder_name в mangas.
  • 🏷 Обновить метатеги — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе done).
  • ↺ Скачать заново — сбрасывает все главы и ставит в очередь повторно.

Карточки манги (кнопки)

Кнопка Условие отображения Действие
всегда Открыть детальное модальное окно
⚠️ N errors_count > 0 Открыть вкладку ошибок в модалке
status = downloading или queued Остановить загрузку
status = stopped или failed Возобновить
🚀 status = queued (только в очереди, не активная) Переместить в начало очереди
всегда Удалить

Позиции в очереди

Отображаются на карточке как «Позиция в очереди: N». Обновляются в реальном времени через событие queue_positions (не перерендер всего списка, а точечное обновление через updateMangaRow). Событие рассылается сервером при каждом изменении состояния очереди.