Привет, Хабр!

Многие из нас в какой-то момент мечтают создать «что-то своё большое». У меня такой мечтой стала система, которая сама отслеживает новинки манги на десятках сайтов и собирает всё в удобную базу. Долгое время это оставалось просто мечтой — опыта не хватало. Но после нескольких небольших пет-проектов (в частности, mini-rostics) я наконец решился и написал Manga-Day — свой первый проект на десятки тысяч строк кода.

В этой статье я расскажу, как всё начиналось, какую архитектуру выбрал, с какими проблемами столкнулся и как их решил. Надеюсь, мой опыт поможет вам быстрее пройти похожий путь.

Об проекте

Логотип Manga-Day, сгенерирован с помощью Gemini
Логотип Manga-Day, сгенерирован с помощью Gemini

У меня была интересная задумка, как бы отслеживать мангу и парсить его с разных источников. Но из‑за недостатка опыта я лишь мог мечтать об этом как о неcбыточной мечте, но благодаря таким проектам как mini‑rostics, я смог поднабраться опыта и написал свой желанный проект!

Первые шаги: почему именно multi-manga

Сразу я выбрал свой любимый сайт - multi-manga. Его HTML-структура остаётся стабильной месяцами и даже годами - идеальный кандидат для парсера. Но одного сайта было мало. Нужно было придумать архитектуру, которая легко масштабируется до 10+ источников.

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

Я большой поклонник асинхронного Python, поэтому сразу решил строить всё на asyncio. Главной единицей стал Spider (паук) - класс, который отвечает за парсинг конкретного сайта.

Чтобы все пауки работали по единому контракту, я создал абстрактный базовый класс. Он включает три обязательных элемента:

BASE_URL = "https://example-manga.com"

Данный параметр хранит в себе оригинальный URL нашего сайта, нужен для того что-бы каждый спайдер мог знать к какому сайту он принадлежит и мог при случаях склеивать пути.

async def get(self, url: str, **kwargs) -> Optional[MangaSchema]: ...

Данный метод предназначен что-бы при получении URL-a мы могли получить объект MangaSchema, который хранит в себе все основные данные, о них ниже

async def pages(
  self, start_page: int | None = None
) -> AsyncGenerator[list[BaseManga], Any]: ...

Данный метод является генератором минимальной манги, и возвращает список BaseManga.

from abc import ABC, abstractmethod

class BaseSpider(ABC):
    BASE_URL = "https://example-manga.com"
    """Базовый класс для спайдера"""

    @abstractmethod
    async def get(self, url: str, **kwargs) -> Optional[MangaSchema]:
        """
        Абстрактный метод для получения данных о манге по URL.

        Args:
            url (str): Ссылка на страницу манги.

        Returns:
            MangaSchema | None: Объект с данными о манге или None, если не удалось получить данные.
        """
    @abstractmethod
    async def pages(
        self, start_page: int | None = None
    ) -> AsyncGenerator[list[BaseManga], Any]:
        """
        Абстрактный метод для генерации пакетов URL-адресов страниц с мангой.

        Args:
            start_page (int): Стартовая страница для парсинга.

        Returns:
            Асинхронный генератор, выдающий списки базовых манг (BaseManga).
        """
        yield []
    

Такой контракт позволил мне в дальнейшем легко добавлять новые источники, не трогая ядро системы.

Схемы данных с Pydantic

Для валидации и сериализации я выбрал Pydantic (он идеально дружит с FastAPI). Были созданы три основные схемы:

BaseManga

Данный класс является матерью для всех манг, и имеет 4 атрибута.

Аргументы

Типы

Описание

title

str

Название тайтла

poster

HttpUrl

Постер на тайтл

url

HttpUrl

Оригинальный URL на тайтл

sku

property(str)

Артикул, генерируется автоматически при помощи sha256

MangaWithGallery

Наследник BaseManga с gallery

Аргументы

Типы

Описание

gallery

list[HttpUrl]

Основной контент

MangaSchema

Наследник MangaWithGallery хранит все основные данные об манге жанры, автор, язык

Аргументы

Типы

Описание

genres

list[str]

Жанры манги

author

str | None

Автор манги

language

str | None

Язык манги

поля, genres, author, language являются необязательными

Где храним данные: SQLAlchemy

Так-как я понимал что манг может быть не просто сотни, а сотни тысяч я начал думать о том как их соединить, и как удобно с ними работать, на помощь пришла sqlalchemy, со своим ORM, и были созданы такие модели как: Genre, Author, Language, Manga. И если с ними всё понятно даже по названию, то с GenreManga, Gallery, GeneratedPdf неясно:

GenreManga

Это связь многое ко многим, создан по причине того что у манги может быть десятки жанров, а у жанров десятки тысяч манг

Gallery

Связь 1 ко 1, создан по причине того что если бы мы хранили галерею вместе с мангой, то при загрузке манги бы так-же тащили с собой галлерею которая может хранить 100+ фотографий

GeneratedPdf

file_id из ТГ бота, пережиток прошлого, в версии 2.1.0 планируется удаление данного класса по причине того что модуль bot был перенесён в отдельный контейнер

Менеджеры: сердце приложения

Теперь когда у нас имеется фундамент нам нужны были менеджеры для работы с данными, для этого были созданы базовые менеджеры, перечислим их по порядку:

MangaManager

Менеджер для добавление и получение манги по артикулу (sku), URL, и по ID

RequestManager

Менеджер запросов, retry логика, запросы и т п

но так-же был добавлены интересные менеджеры

AlertManager

Менеджер уведомлений в реальном времени, интересен тем что он может работать с любыми классами которые будут наследованы от BaseAlert, можно интегрировать Whatsapp, Telegram, Discord и многое другое, лишь нужно реализовать класс с методом alert

SpiderManager

Самый фундаментальный и важный менеджер управляет всеми пауками, и имеет особенность загружать их динамически, то есть не нужно лезть в код менеджера что-бы просто добавить нового паука в поле, а просто нужно добавить паука в __init.py__ в переменной __all__ и так-как менеджер был бы слишком громоздким, он был разделён на 4 файла, для лёгкости тестирование.

  1. _load.py - Загрзка всех пауков, и передача зависимостей

  2. _status.py - Модели Pydantic и Enum

  3. _starter.py - Запуск пауков

  4. _spider.py - Место где все вышеперечисленные атрибуты создают единый класс для работы с пауками, отслеживание статусов и запуск

Сервисы и великий переход на микросервисы

Изначально в проекте было два сервиса: FindService (поиск и пагинация по жанрам, автору, языку, названию) и PDFservice.

PDFservice оказался настоящим «пожирателем» RAM. После нескольких инцидентов с памятью я вынес его в отдельный контейнер. Это стало отправной точкой «великого переноса»:

  1. Frontend — убрал все прямые импорты из ядра, перешёл на чистый API + минимальный Jinja только для конфигов. Реакт и другие фронтенд-фреймворки я не знаю (я чистый бэкендер), поэтому сделал максимально просто.

  2. Bot — самый болезненный модуль. Потратил три дня только на то, чтобы понять, как подключиться к WebSocket через aiohttp. Плюс пришлось решать вопросы JWT-авторизации и админских прав.

После этих изменений проект стал по-настоящему масштабируемым и удобным в поддержке.

Вывод и что дальше

Manga-Day - мой первый действительно большой проект. Я горжусь тем, как он вырос из простой идеи в продуманную микросервисную архитектуру.

Посмотреть проект вживую можно здесь: manga-day

Буду рад любому фидбэку: код-ревью, предложениям по улучшению, pull request’ам и даже жёсткой критике. Особенно интересно услышать мнение тех, кто уже прошёл путь от пет-проектов к продакшен-системам.

Спасибо, что дочитали! Если статья была полезна — ставьте плюс, пишите в комментариях, какие темы раскрыть подробнее в следующих статьях.

Комментарии (0)