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

У меня была интересная задумка, как бы отслеживать мангу и парсить его с разных источников. Но из‑за недостатка опыта я лишь мог мечтать об этом как о не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 файла, для лёгкости тестирование.
_load.py - Загрзка всех пауков, и передача зависимостей
_status.py - Модели Pydantic и Enum
_starter.py - Запуск пауков
_spider.py - Место где все вышеперечисленные атрибуты создают единый класс для работы с пауками, отслеживание статусов и запуск
Сервисы и великий переход на микросервисы
Изначально в проекте было два сервиса: FindService (поиск и пагинация по жанрам, автору, языку, названию) и PDFservice.
PDFservice оказался настоящим «пожирателем» RAM. После нескольких инцидентов с памятью я вынес его в отдельный контейнер. Это стало отправной точкой «великого переноса»:
Frontend — убрал все прямые импорты из ядра, перешёл на чистый API + минимальный Jinja только для конфигов. Реакт и другие фронтенд-фреймворки я не знаю (я чистый бэкендер), поэтому сделал максимально просто.
Bot — самый болезненный модуль. Потратил три дня только на то, чтобы понять, как подключиться к WebSocket через aiohttp. Плюс пришлось решать вопросы JWT-авторизации и админских прав.
После этих изменений проект стал по-настоящему масштабируемым и удобным в поддержке.
Вывод и что дальше
Manga-Day - мой первый действительно большой проект. Я горжусь тем, как он вырос из простой идеи в продуманную микросервисную архитектуру.
Посмотреть проект вживую можно здесь: manga-day
Буду рад любому фидбэку: код-ревью, предложениям по улучшению, pull request’ам и даже жёсткой критике. Особенно интересно услышать мнение тех, кто уже прошёл путь от пет-проектов к продакшен-системам.
Спасибо, что дочитали! Если статья была полезна — ставьте плюс, пишите в комментариях, какие темы раскрыть подробнее в следующих статьях.