Интеграция Tilda и Django

Как сделать так, чтобы контент из Тильды легко выводился в шаблоне вашего сайта? В этом материале я покажу пример реализации этой задаче на Tilda, Django и кодом на Python.

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

Первое решение, которое приходит на ум -- просто экспортнуть код из тильды в виде архива и загрузить к нам в папку на сервере.

Экспорт кода из тильды в виде zip-архива
Экспорт кода из Тильды в ZIP-архив

Но у этого метода есть масса недостатков.

  • Подвала нет
  • Навигация отсутствует
  • Редактор не может поправить контент
  • Нигде в админке не отображается список этих статей
  • Статья выглядит чужеродной и никак не встроенной в сайт.
  • Переместить статью просто так не получится, нужно будет перемещать файлы
  • Невозможно массово отредактировать что-то внутри этих статей

В общем, все недостатки, присущие статичным файлам, выложенным на сервере.

Цель этой статьи - показать вам метод лучше.

Метод лучше

Что, если мы сохраним у себя в базе данных хтмл-код статьи и будем выводить его в нашем шаблоне? С нашей шапкой и подвалом? Тогда у нас будет вроде бы как обычная статья, хранящаяся в базе данных, но сделанная на Тильде. Это решило бы все проблемы из списка выше.

Наша вьюшка выглядела бы примеро так:

def tilda_article(request, pk):
    article = get_object_or_404(TildaArticle, pk=pk)
    return render('tilda_article.html', article=article)

а шаблон так:

... header ...
{{ article.styles }}
{{ article.scripts }}
{{ article.tilda_content }}
... footer ...

Не будут ли при этом конфликтовать стили и скрипты, ведь все будет выводиться на одной странице?

Я провел несколько опытов и выяснил, что стили и скрипты Тильды не конфликтуют с окружающим контентом сайта. Тильда довольно назависима и хорошо выживает в чужеродной среде.

Пока неплохо. Но как это будет выглядеть со стороны администратора?

Сценарий использования

  1. Редактор заходит в стандартную админку джанги, создает новую Статью
  2. Выгружает из Тильды zip-архив, вставляет его специальное поле
  3. Сохраняет
  4. Джанга распаковывает архив в куда-то в MEDIA_ROOT, а контент статьи в виде готового хтмл-кода сохраняет в базу данных

Само собой при этом должны работать все рисунки и стили и скрипты.

Звучит просто?

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

Что в архиве Тильды?

Экспорт из Тильды - это файл с расширением .zip следующего содержания:

$ zipinfo -1 project1556959.zip
/project1556959/
/project1556959/files/
/project1556959/images/
/project1556959/css/
/project1556959/js/
/project1556959/images/tild6635-6563-4934-b537-356439633464__plast.png
/project1556959/css/tilda-grid-3.0.min.css
/project1556959/css/tilda-blocks-2.12.css
/project1556959/css/tilda-animation-1.0.min.css
/project1556959/css/tilda-slds-1.4.min.css
/project1556959/css/tilda-zoom-2.0.min.css
/project1556959/js/jquery-1.10.2.min.js
/project1556959/js/tilda-scripts-2.8.min.js
/project1556959/js/tilda-blocks-2.7.js
/project1556959/js/lazyload-1.3.min.js
/project1556959/js/tilda-animation-1.0.min.js
/project1556959/js/tilda-slds-1.4.min.js
/project1556959/js/hammer.min.js
/project1556959/js/tilda-zoom-2.0.min.js
/project1556959/images/tild6635-6563-4934-b537-356439633464__-__resize__504x__plast.png
/project1556959/images/tild6635-6563-4934-b537-356439633464__-__empty__plast.png
/project1556959/images/tild6539-6531-4635-b262-633039613131__-__empty__mark.png
/project1556959/images/tild6539-6531-4635-b262-633039613131__mark.png
/project1556959/files/page6917313body.html
/project1556959/page6917313.html
/project1556959/htaccess
/project1556959/readme.txt
/project1556959/images/tildafavicon.ico
/project1556959/images/tildacopy.png
/project1556959/images/tildacopy_black.png
/project1556959/404.html
/project1556959/robots.txt
/project1556959/sitemap.xml

Основной файл с контентом страницы - page6917313.html. Это полный хтмл со всеми заголовками и телом.

Стили и скрипты в нем прописаны c относительными путями:

<!-- Assets -->
<link rel="stylesheet" href="css/tilda-grid-3.0.min.css" type="text/css" media="all" />
<link rel="stylesheet" href="css/tilda-blocks-2.12.css?t=1498182256" type="text/css" media="all" />
<script src="js/jquery-1.10.2.min.js"></script>
<script src="js/tilda-scripts-2.8.min.js"></script>
<script src="js/tilda-blocks-2.6.js?t=1498182256"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/jquery.touchswipe.min.js"></script>
<script src="js/tilda-forms-1.0.min.js"></script>
<script src="js/lazyload-1.3.min.js">

Парсингом их можно выдрать из тела и сохранить. Это не проблема.

Трудность в том, что если наша статья выводится по адресу /articles/some/, а архив  распакован где-то в media/tilda/xxx, то нам нужно поправить пути к ассетам на абсолютные.

Архитектура решения

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

Сделаем модель:

class TildaArticle(models.Model):
    tilda_content = models.TextField(u'Tilda code', blank=True)
    styles = models.TextField(u'Стили', blank=True)
    scripts = models.TextField(u'Скрипты', blank=True)
    archive = models.FileField(u'Импорт из файла', blank=True, null=True,
    	                       upload_to=tilda_upload_to)

    def import_archive(self):
    	"""Распаковать и импортировать загруженный в `archive` файл из Тильды"""
        if self.archive:
        	pass  # extract archive...

В поле archive редактор будет загружать zip-файл, который нам нужно автоматически распаковать.

В styles и scripts будет храниться хтмл-код со ссылками на нужные для этой статьи ассеты. Данные для этих полей мы будем получать парсингом основного файла pageXXXX.html.

Чтобы распаковывать файл сразу после загрузки в админке, повесим обработчик в save_model:

@admin.register(TildaArticle)
class TildaArticleAdmin(BaseMaterialAdmin):
    def save_model(self, request, obj, form, change):
        # распаковка архива при импорте
        archive_changed = 'archive' in form.changed_data

        # запишет файл на диск
        super(TildaArticleAdmin, self).save_model(request, obj, form, change)

        if archive_changed and obj.archive:
            obj.import_archive()

Теперь при загрузке нового файла в поле archive будет автоматически вызываться код распаковки и парсинга в функции model.import_archive().

Давайте напишем его.

Архитектура распаковщика

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

Требования:

Распаковывать не все файлы

robots.txt или htaccess, которые по-умолчанию добавлены в каждый зип-архив, нам не нужны. В целом, нам нужны только файлы из папок images, css и js.

Избавиться от папки project1234 в распакованных файлах

Из файлов нужно парсить данные

В файле pageXXX.html есть куски кода, ссылающиеся на стили и классы. Их нужно выдрать, чтобы потом сохранить в модель.

Расширяемость

Класс должен быть расширяемым, чтобы другие программисты просто дописали нужные им конкретные реализации. Может быть кто-то и не станет парсить файлы.

При написании я вдохновился примером Django Syndication Framework.

Вся работа по открытию, перебору и сохранению файлов спрятана где-то внутри базового класса, а мои юзер-логичные функции я просто прицепляю в нескольких местах, переписывая наследника, и фреймворк их вызывает в процессе обработки.

В основе лежит вот такой базовый класс.


class TildaArchive(object):
    def __init__(self, path):
        self.path = path

    def content(self, zipinfo, f):
        pass

    def extract_path(self, zipinfo):
        pass

    def done(self):
        pass

    def process(self):
        with zipfile.ZipFile(self.path) as zf:
            for zipinfo in zf.infolist():
                # парсинг контента
                with zf.open(zipinfo) as f:
                    self.content(zipinfo, f)

                # распаковка
                with zf.open(zipinfo) as f:
                    save_as = self.extract_path(zipinfo)
                    if save_as:
                        self.save(f, save_as)

            self.done()

    def save(self, source, targetpath):
        # Create all upper directories if necessary
        upperdirs = os.path.dirname(targetpath)
        if upperdirs and not os.path.exists(upperdirs):
            os.makedirs(upperdirs)

        with file(targetpath, "wb") as target:
            shutil.copyfileobj(source, target)

        return targetpath

Он открывает архив и перебирает файлы. Для каждого файла:

  • содержимое передает на обработку в функцию content;
  • сохраняет файл по адресу, который вернет функция extract_path. А если она возвращает False, то файл пропускается.

После обработки всех файлов вызывается функция done - это место для хука, который сохраняет все накопленные парсингом данные из разных файлов.

Вы можете работать с этим классом, унаследовавшись от него и написав свой код для функций content, extract_path и done. Сейчас я покажу, как это сделал я, но сначала напишем пример использования класса из нашей модели TildaArticle.

    def import_archive(self):
        """Распаковать и импортировать загруженный в `archive` файл из Тильды"""
        if self.archive:
            archive = IrkruTildaArchive(self.archive, material=self)
            archive.process()

И, собственно, моя реализация с парсингом:


class IrkruTildaArchive(TildaArchive):
    def __init__(self, path, material):
        super(IrkruTildaArchive, self).__init__(path)

        self.material = material
        self.styles = None
        self.scripts = None
        self.body = None

        self.extract_root = material.tilda_extract_root
        self.extract_url = material.tilda_extract_url

    def content(self, zipinfo, f):
        """
        Из html-файла парсит ссылки на стили и скрипты
        """
        filename = self.strip_project(zipinfo.filename)

        if re.match(r'page\d+.html', filename):
            html = f.read()
            self.styles, self.scripts = self.assets(html)
        elif re.match(r'files/page\d+body.html', filename):
            self.body = f.read().decode('utf-8')

    def done(self):
        """
        Вызывается после обработки всех файлов
        """
        if self.styles:
            self.material.styles = '\n'.join(self.styles)

        if self.scripts:
            self.material.scripts = '\n'.join(self.scripts)

        if self.body:
            self.material.tilda_content = self.body

        self.material.save()

    def extract_path(self, zipinfo):
        filename = self.strip_project(zipinfo.filename)

        if self.is_css(filename) or self.is_js(filename) or self.is_image(filename):
            return os.path.join(self.extract_root, filename)

    @staticmethod
    def assets(html):
        styles, scripts = None, None

        link_pattern = re.compile(r'''<link[^>]+rel=["']stylesheet["'].+?>''')
        styles = link_pattern.findall(html)

        link_pattern = re.compile(r'''<script\s+src=["'].+?></script>''')
        scripts = link_pattern.findall(html)

        return styles, scripts

Я не включил описание нескольких вспомогательных методов, их код вы найдете в полной версии в конце статьи.

Шаблонные фильтры

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

Для этого я использую дополнительные шаблонные теги:

# app/templatetags/tilda_tags.py

@register.simple_tag
def tilda_scripts(article):
    return mark_safe(article.prepare_scripts())


@register.simple_tag
def tilda_styles(article):
    return mark_safe(article.prepare_styles())


@register.simple_tag
def tilda_content(article):
    return mark_safe(article.prepare_content())

Код в модели:

class TildaArticle(models.Model):

    ...

    def prepare_content(self):
        """Возвращает готовый к выводу хтмл с поправленными путями к изображениям"""
        result = self.tilda_content.replace('="images/', '="{}images/'.format(self.tilda_extract_url))
        result = result.replace("url('images/", "url('{}images/".format(self.tilda_extract_url))
        return result

    def prepare_scripts(self):
        return self.scripts.replace('src="js/', 'src="{}js/'.format(self.tilda_extract_url))

    def prepare_styles(self):
        return self.styles.replace('href="css/', 'href="{}css/'.format(self.tilda_extract_url))

И вывод в шаблоне:

{% load tilda_tags %}

<html>
<head>
    {% tilda_styles article %}
    {% tilda_scripts article %}
</head>
<body>
    {% tilda_content article %}
</body>

Ссылки по теме

save_model: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#modeladmin-methods


Полный код примера

Я собрал небольшое приложение, полностью функционирующее и содержащее логику из статьи.

Ссылка: https://github.com/dpetukhov/tilda-django