Цель статьи - описать создание инфраструктуры для парсинга на базе python, Django, Celery и Docker.
Введение
В студенческие годы я написал на заказ много парсеров магазинов и социальных сетей. Со временем парсеры усложнялись и из скриптов превращались в полноценные веб-приложения c базой данных и Rest API. В статье описан шаблон веб-приложения, который использую для создания парсеров.
Парсинг (в контексте статьи) -- это автоматизированный процесс извлечение данных из Интернета.
Из статьи мы узнаем
Как создавать Rest API на базе Django Rest Framework
Как создавать асинхронные задачи с помощью Celery
Как деплоить веб-пиложение с использованием Docker-compose
Архитектура приложения содержит 3 основые части:
Django для обработки HTTP запросов и хранения данных
Redis - транспорт (брокера сообщений)
Celery - для создания очередей задач (задач парсинга)
UseCase:
Пользователь делает HTTP POST запрос /task
Если запрос содержит правильные данные, то приложение запускает задачу парсинга
В базу данных сохраняются результаты задачи
1 Начнем с создания Django приложения
# Create Django application
python3.9 -m venv venv # create virtual environment
source venv/bin/activate # activate the environment
pip install Django==4.0.0 # install django library
mkdir project
cd project
django-admin startproject core_app . # create django app with initial settings
Для аккуратности создадим отдельное приложение (директорию), код которого будет отвечать за логику приложения (парсинг)
# create a new app in /project
python manage.py startapp parser_app
Результат первой части
После выполнения команд у нас должен получиться такой проект:
project/
├── core_app
│ ├── asgi.py
│ ├── __init__.py
│ ├── __pycache__
│ │ ├── __init__.cpython-39.pyc
│ │ └── settings.cpython-39.pyc
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
└── parser_app
├── admin.py
├── apps.py
├── __init__.py
├── migrations
│ └── __init__.py
├── models.py
├── tests.py
└── views.py
2 Запустим приложение в Docker
2.1 Подготовка docker-compose
Создаем .env файл с переменными окружения
# .env
DB_USER=postgres
DB_PASSWORD=post222
DB_NAME=parsing_db
DB_PORT=5444
DATABASE_URL=postgres://postgres:post222@db:5432/parsing_db"
DEBUG=1
Модифицируем файл с настройками
# core_app/settings.py
import os
import environ
env = environ.Env()
ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# packages
'rest_framework',
# my apps
'parser_app',
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ['DB_NAME'],
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': 'db',
'PORT': 5432,
}
}
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static'
Описываем библиотеки, которые потребуются для запуска проект
# project/requirements.txt
Django==4.0
celery==5.2.6
djangorestframework==3.13.1
redis==3.4.1
django-environ==0.8.1
gunicorn==20.1.0
psycopg2==2.9.3
celery==5.2.6
redis==3.4.1
requests==2.23.0
lxml==4.8.0
2.2 Описываем инструкции для сборки докер-образа в Dockerfile
# project/Dockerfile
FROM python:3.9-slim-bullseye
WORKDIR /project
# forbid .pyc file recording
# forbid bufferization
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONBUFFERED=1
COPY . .
RUN apt-get update && apt-get install --no-install-recommends -y \
gcc libc-dev libpq-dev python-dev libxml2-dev libxslt1-dev python3-lxml && apt-get install -y cron &&\
pip install --no-cache-dir -r requirements.txt
Конфигурируем nginx
# project/nginx-conf.d/nginx-conf.conf
upstream app {
server django:8000;
}
server {
listen 80;
server_name 127.0.0.1;
location / {
proxy_pass http://django:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
location /static/ {
alias /var/www/html/static/;
}
}
Создаем docker-compose.yml на одном уровне с папкой project/
# docker-compose.yml
version: '3.9'
services:
django:
build: ./project # path to Dockerfile
command: sh -c "
python manage.py makemigrations
&& python manage.py migrate
&& gunicorn --bind 0.0.0.0:8000 core_app.wsgi"
volumes:
- ./project:/project
- ./project/static:/project/static
expose:
- 8000
env_file:
- .env
db:
image: postgres:13-alpine
volumes:
- pg_data:/var/lib/postgresql/data/
expose:
- 5432
env_file:
- .env
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
nginx:
image: nginx:1.19.8-alpine
depends_on:
- django
ports:
- "80:80"
volumes:
- ./project/static:/var/www/html/static
- ./project/nginx-conf.d/:/etc/nginx/conf.d
volumes:
pg_data:
static:
Запускаем приложение
docker-compose up --build -d
Далее зайдем в докер-контейнер и создадим суперпользователя
docker ps # intended to find django container name --> code_django_1
>> 6f5db39cfa3b code_django "sh -c ' python mana…" 47 seconds ago Up 46 seconds 8000/tcp code_django_1
docker exec -ti code_django_1 bash # go into the container
python manage.py createsuperuser # create admin user in django
python manage.py collectstatic # intended to load css and js files
exit
Результат второй части
Заходим на 127.0.0.1 Должен быть такой результат:
3 Подключаем Celery
Добавляем в docker-compose.yml файл новые сервисы
celery:
build: ./project
command: celery -A parser_app worker --loglevel=info
volumes:
- ./project:/usr/src/app
env_file:
- .env
environment:
# environment variables declared in the environment section override env_file
- DEBUG=1
- DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
- CELERY_BROKER=redis://redis:6379/0
- CELERY_BACKEND=redis://redis:6379/0
depends_on:
- django
- redis
redis:
image: redis:5-alpine
volumes:
pg_data:
static:
Добавляем настройки для celery
# settings.py
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_BROKER", "redis://redis:6379/0")
CELERY_IMPORTS = ("parser_app.celery",)
Создаем объект для работы с celery
# parser_app/celery.py
"""
Celery config file
https://docs.celeryproject.org/en/stable/django/first-steps-with-django.html
"""
from __future__ import absolute_import
import os
from celery import Celery
from core_app.settings import INSTALLED_APPS
# this code copied from manage.py
# set the default Django settings module for the 'celery' app.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core_app.settings')
# you change the name here
app = Celery("parser_app")
# read config from Django settings, the CELERY namespace would make celery
# config keys has `CELERY` prefix
app.config_from_object('django.conf:settings', namespace='CELERY')
# load tasks.py in django apps
app.autodiscover_tasks(lambda: INSTALLED_APPS)
Импортируем celery приложени, чтобы оно запускалось вместе с django
# parser_app/__init__.py
from __future__ import absolute_import, unicode_literals
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ('celery_app',)
4 Переходим к логике парсинга
Мы будем парсить сайт https://books.toscrape.com/ так как сайт предназначен для парсинга и не изменится. В HTTP POST запросе пользователь будет передавать название категории (напрмер, mystery_3)
Программа будет сохранять названия книг из этой категории
Начнем с создания модели данных
from django.db import models
class BaseTask(models.Model):
""" Celery task info"""
name = models.CharField(max_length=100)
is_success = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class BaseParsingResult(models.Model):
""" Parsing result details"""
task_id = models.ForeignKey(
BaseTask,
blank=True,
null=True,
on_delete=models.PROTECT
)
data = models.TextField(blank=True)
task_type = models.CharField(blank=True, max_length=64)
Изменения в urls.py файлах. У нвс будет всего один запрос task/ -- постановка задачи на пасинг и мониторнг результатов
# core_app/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('parser_app.urls'))
]
# parser_app/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('task', views.task, name='task'),
]
В файле views.py описываем обработчик запроса task/
GET -- возвращает статус задачи, POST -- ставит задачу на парсинг
from django.shortcuts import render
from rest_framework.decorators import api_view
from rest_framework.response import Response
from celery.result import AsyncResult
from parser_app.tasks import create_task
@api_view(['GET', 'POST'])
def task(request):
if request.method == 'POST':
if "type" in request.data:
category_name = request.data["type"]
task = create_task.delay(category_name) # create celery task
return Response({"message": "Create task", "task_id": task.id, "data": request.data})
else:
return Response({"message": "Error, not found 'type' in POST request"})
if request.method == 'GET': # get task status
if "task_id" in request.data:
task_id = request.data["task_id"]
task_result = AsyncResult(task_id)
result = {
"task_id": task_id,
"task_status": task_result.status,
"task_result": task_result.result
}
return Response(result)
else:
return Response({"message": "Error, not found 'task_id' in GET request"})
Создаем файл tasks.py с описание логики работы задачи парсинга
import requests
import time
from lxml import etree
from datetime import datetime
from parser_app.models import BaseTask, BaseParsingResult
from core_app.celery import app
from django.core.cache import cache
def parse_data(celery_task_id: str, category_name: str):
new_task = BaseTask.objects.create(
name=celery_task_id,
)
new_task.save()
try:
response = requests.get(
f"https://books.toscrape.com/catalogue/category/books/{category_name}/"
)
if response.status_code == 200:
tree = etree.HTML(response.content)
results = tree.xpath("//article/h3/a")
for cur in results:
cur_parsing_res = BaseParsingResult.objects.create(
task_id=new_task,
data=cur.text,
task_type=category_name
)
cur_parsing_res.save()
except Exception as e:
print("Error: ", e)
else:
new_task.is_success = True
new_task.save()
@app.task(name='create_task', bind=True)
def create_task(self, category_name):
parse_data(self.request.id, category_name)
return True
Добавляем в админку данные
from django.contrib import admin
from .models import BaseTask, BaseParsingResult
@admin.register(BaseTask)
class BaseTaskAdmin(admin.ModelAdmin):
list_display = ['id', 'name', 'is_success', 'created_at']
readonly_fields = ['created_at']
list_filter = ['is_success']
@admin.register(BaseParsingResult)
class BaseResultAdmin(admin.ModelAdmin):
list_display = ['id', 'task_id', 'data', 'task_type']
Перезапускаем приложение.
5 Использование приложения
5.1 Запрос парсинга POST
# POST http://127.0.0.1:80/task
{
"type": "philosophy_7"
}
RESPONSE:
{
"message": "Create task",
"task_id": "062ac81f-dafe-4e2c-95e9-c042936e85f3",
"data": {
"type": "philosophy_7"
}
}
5.2 Посмотрим результат задачи
# GET http://127.0.0.1:80/task
{
"task_id": "062ac81f-dafe-4e2c-95e9-c042936e85f3"
}
RESPONSE:
{
"task_id": "062ac81f-dafe-4e2c-95e9-c042936e85f3",
"task_status": "SUCCESS",
"task_result": true
}
5.3 Результаты работы в админке
Приложение готово!
Буду признателен за фидбек :-)