Как я перенёс материалы K2 на архивный сайт и освободил домен для нового проекта

Недавно я решил вдохнуть новую жизнь в один из своих старых доменов. Но перед этим нужно было освободить его от груза прошлого — сотен материалов, созданных в K2 на сайте vizator.ru. Эти статьи, чертежи и заметки были слишком ценны, чтобы просто удалить их, поэтому я задумал перенести всё на архивный сайт jedig.ru, работающий на Joomla. Звучит просто, правда? Но, как оказалось, это был настоящий квест с кучей головоломок, которые пришлось разгадывать на ходу.

Зачем это всё?

Домен vizator.ru уже давно просился под новый проект — что-то свежее, современное, без старого багажа. Но бросить материалы K2 было жалко: годы работы, уникальные чертежи, полезные заметки. Решение пришло само собой — перенести всё на jedig.ru, мой архивный сайт, где уже крутится Joomla. Задача: сохранить контент, изображения и структуру, чтобы потом можно было спокойно перезапустить vizator.ru с чистого листа. И вот я взялся за дело.

Инструмент для переноса: Python-скрипт

Ручной перенос сотен статей? Нет уж, я не настолько мазохист! Поэтому я написал скрипт на Python, который должен был сделать всю грязную работу: вытащить данные из K2, перенести их в Joomla и обновить ссылки на изображения. Вот как он работает:

  • Извлечение данных: Скрипт подключается к базе K2 (таблица viz_k2_items) и вытягивает всё: заголовки, тексты (introtext и fulltext), алиасы, даты, просмотры.
  • Копирование изображений: Находит изображения в папке K2 (/var/www/www-root/data/www/vizator.ru/media/k2/items/cache/), копирует их в новую папку Joomla (/var/www/www-root/data/www/jedig.ru/images/articals/cars/).
  • Обновление ссылок: Использует регулярное выражение <img[^>]+src=["']?([^"\s>]+)["']?[^>]*> для поиска путей в тегах <img> и заменяет старые пути (images/items/) на новые (images/articals/cars/).
  • Запись в Joomla: Добавляет или обновляет записи в таблицах Joomla: dyutb_content (статьи), dyutb_assets (права доступа), dyutb_workflow_associations (рабочий процесс).

Звучит как идеальный план, да? Но, как говорится, теория и практика — это два разных мира.

Проблемы, с которыми я столкнулся

Когда я запустил скрипт, он начал бодро работать… а потом всё пошло наперекосяк. Вот что пришлось чинить:

1. Ссылки обновились не везде

Я заметил, что часть ссылок на изображения осталась старыми — images/items/ вместо images/articals/cars/. Сначала подумал: "Ну ладно, регулярка слабая, пропустила что-то". Проверил логи — нет, для обработанных статей всё обновилось. Оказалось, скрипт просто падал на середине из-за другой ошибки, и до некоторых материалов дело не доходило. Пришлось копаться глубже.

2. Ошибка с длиной заголовка

В логах вылезло: (1406, "Data too long for column 'title' at row 1"). Я чуть не заорал: "Да что ж такое!". Оказалось, что в таблице dyutb_assets колонка title ограничена 100 символами, а я пытался засунуть туда полные заголовки вроде "Чертеж конусного ограничителя задних пружин для Land Rover Defender 90, Discovery 1 и стареньких RRC (TF510)" (100 символов ровно, но были и длиннее). В dyutb_content лимит 255, и там всё нормально, а вот assets подставил подножку.

Решение: для dyutb_assets стал писать просто "com_content", как у старых материалов. Оказалось, что это поле особо и не нужно для фронтенда — главное заголовок в content. Проблема ушла, и я выдохнул.

3. Пересохранение в админке не работало

Когда я зашёл в админку Joomla проверить результат, попытался пересохранить статью — и получил красный баннер: "danger Не удалось сохранить элемент". Тут я уже начал подозревать, что вселенная против меня. Копнул в dyutb_assets и увидел, что parent_id для новых статей был равен 8. Откуда эта восьмёрка? Скрипт брал её из записи с name = 'com_content', думая, что это корень для всех статей.

На самом деле parent_id должен был быть 192 — ID записи в dyutb_assets, привязанной к категории 14 (моей целевой категории). Без этого Joomla теряла связь между статьёй и категорией, и пересохранение ломалось. Исправил на фиксированное parent_id = 192, и админка ожила. Ура!

Код, который всё спас

Вот кусок кода, который в итоге всё вытянул. Он обрабатывает материал, копирует изображения и записывает данные в Joomla:

# Установка правильного parent_id для категории
parent_id = 192

# Извлечение материала из K2
k2_cursor.execute("SELECT `id`, `title`, `alias`, `introtext`, `fulltext`, `created`, `hits` FROM `viz_k2_items`")
k2_items = k2_cursor.fetchall()

for item in k2_items:
    # Обрезка заголовка для dyutb_content
    content_title = item['title'][:255] if len(item['title']) > 255 else item['title']
    
    # Обновление ссылок в introtext
    updated_introtext = item['introtext'] or ""
    intro_images = re.findall(r'<img[^>]+src=["']?([^"\s>]+)["']?[^>]*>', updated_introtext)
    for image in intro_images:
        new_image_name = copy_image(image, new_image_folder)
        if new_image_name:
            updated_introtext = updated_introtext.replace(image, f"images/articals/cars/{new_image_name}")

    # Запись в dyutb_content
    jedig_cursor.execute("UPDATE `dyutb_content` SET `title` = %s, `introtext` = %s WHERE `alias` = %s", 
                         (content_title, updated_introtext, item['alias']))

    # Запись в dyutb_assets с правильным parent_id
    asset_name = f"com_content.article.{content_id}"
    jedig_cursor.execute("INSERT INTO `dyutb_assets` (`parent_id`, `name`, `title`) VALUES (%s, %s, %s)", 
                         (parent_id, asset_name, "com_content"))

Полный код длиннее, но суть та же: аккуратно перенести данные и не сломать сайт.

Итог

После всех мучений я получил то, что хотел: все материалы из K2 теперь живут на jedig.ru, ссылки на изображения обновлены, админка работает как надо. Домен vizator.ru свободен для нового проекта, и я могу спать спокойно, зная, что старый контент не пропал. Да, пришлось попотеть с заголовками, ссылками и parent_id, но результат того стоил. Теперь я чувствую себя немного героем, который победил технического дракона!

Если у вас похожая задача — берите мой опыт и не повторяйте моих ошибок. Удачи в ваших миграциях!

Полный код предоставляется в качестве подарка. Этот код также позволяет перенести материалы K2 в рамках одного сайта. Теоретически возможно перенести и категории, но у меня не было такой задачи.

import pymysql  # Библиотека для работы с MySQL
import os  # Для работы с файловой системой и переменными окружения
import re  # Для работы с регулярными выражениями (поиск и замена ссылок)
import shutil  # Для копирования файлов (изображений)
from dotenv import load_dotenv  # Для загрузки переменных из файла .env
import logging  # Для логирования процесса (отладка и контроль)
import hashlib  # Для генерации уникальных имен файлов (хэш MD5)
from datetime import datetime  # Для работы с датами (например, modified)
import json  # Для работы с JSON-форматированными данными (urls, attribs, metadata)

# Загружаем переменные окружения из файла .env (например, данные для подключения к БД)
load_dotenv()

# Настраиваем логирование: выводим сообщения с временем, уровнем и текстом
logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(message)s')

# Конфигурация подключения к базе K2 (источник данных)
k2_db_config = {
    'host': os.getenv('VIZATOR_MYSQL_HOST'),  # Хост базы данных K2
    'user': os.getenv('VIZATOR_MYSQL_USER'),  # Пользователь
    'password': os.getenv('VIZATOR_MYSQL_PASSWORD'),  # Пароль
    'db': os.getenv('VIZATOR_MYSQL_DATABASE'),  # Имя базы
    'charset': 'utf8mb4',  # Кодировка для поддержки Unicode
    'cursorclass': pymysql.cursors.DictCursor  # Курсор возвращает словари
}

# Конфигурация подключения к базе Joomla (целевая база)
jedig_db_config = {
    'host': os.getenv('JEDIG_MYSQL_HOST'),
    'user': os.getenv('JEDIG_MYSQL_USER'),
    'password': os.getenv('JEDIG_MYSQL_PASSWORD'),
    'db': os.getenv('JEDIG_MYSQL_DATABASE'),
    'charset': 'utf8mb4',
    'cursorclass': pymysql.cursors.DictCursor
}

# Префиксы таблиц для K2 и Joomla
k2_table_prefix = 'viz_k2'  # Префикс таблиц K2
jedig_table_prefix = 'dyutb_'  # Префикс таблиц Joomla

# Пути к изображениям
k2_image_base_path = '/var/www/www-root/data/www/vizator.ru/'  # Базовый путь к файлам K2
new_image_folder = '/var/www/www-root/data/www/jedig.ru/images/articals/cars/'  # Новый путь для копирования изображений
os.makedirs(new_image_folder, exist_ok=True)  # Создаём папку, если её нет
new_image_relative_path = 'images/articals/cars/'  # Относительный путь для ссылок в Joomla

# Регулярное выражение для поиска путей в тегах <img>
image_pattern = re.compile(r'<img[^>]+src=["\']?([^"\s>]+)["\']?[^>]*>')
# Захватывает src с кавычками или без, до пробела или конца тега

# Значения по умолчанию для полей Joomla
default_urls = json.dumps({"urla": False, "urlatext": "", "targeta": "", "urlb": False, "urlbtext": "", "targetb": "", "urlc": False, "urlctext": "", "targetc": ""})
# JSON для дополнительных ссылок в статье
default_attribs = json.dumps({"article_layout": "", "show_title": "", "link_titles": "", "show_tags": "", "show_intro": "", "info_block_position": "", "info_block_show_title": "", "show_category": "", "link_category": "", "show_parent_category": "", "link_parent_category": "", "show_associations": "", "show_author": "", "link_author": "", "show_create_date": "", "show_modify_date": "", "show_publish_date": "", "show_item_navigation": "", "show_icons": "", "show_print_icon": "", "show_email_icon": "", "show_vote": "", "show_hits": "", "show_noauth": "", "urls_position": "", "alternative_readmore": "", "article_page_title": "", "show_publishing_options": "", "show_article_options": "", "show_urls_images_backend": "", "show_urls_images_frontend": ""})
# JSON для настроек отображения статьи
default_metadata = json.dumps({"robots": "", "author": "", "rights": "", "xreference": ""})
# JSON для мета-данных
default_rules = json.dumps({
    "core.view": {"1": 1},  # Права просмотра для всех
    "core.create": {"8": 1}, "core.delete": {"8": 1}, "core.edit": {"8": 1}, "core.edit.state": {"8": 1}
    # Права для группы с ID 8 (например, администраторы)
})
# JSON для правил доступа

def generate_image_filename(item_id):
    """Генерирует уникальное имя файла изображения на основе ID материала."""
    return hashlib.md5(f"Image{item_id}".encode()).hexdigest() + "_L.jpg"
    # Хэш MD5 от строки "Image<Id>" плюс суффикс "_L.jpg"

def extract_images(text):
    """Извлекает все пути к изображениям из текста с помощью регулярного выражения."""
    return image_pattern.findall(text)  # Возвращает список путей из атрибутов src

def update_image_links(text, old_path, new_path):
    """Заменяет старый путь изображения на новый в тексте."""
    if not old_path or old_path == new_path:
        logging.warning(f"Старый путь '{old_path}' пуст или совпадает с новым '{new_path}'. Ссылки не обновлены.")
        return text
    
    pattern = re.escape(old_path)  # Экранируем путь для безопасной замены
    updated_text = re.sub(rf'(<img[^>]+src=["\']?){pattern}(["\']?[^>]*>)', rf'\1{new_path}\2', text)
    # Заменяем только src, сохраняя остальной тег
    
    if updated_text != text:
        logging.info(f"Обновлена ссылка: '{old_path}' → '{new_path}'")
    else:
        logging.warning(f"Ссылка '{old_path}' не найдена в тексте для замены на '{new_path}'")
    
    return updated_text

def copy_image(image_path, destination_folder):
    """Копирует изображение из K2 в новую папку Joomla."""
    absolute_image_path = os.path.join(k2_image_base_path, image_path)  # Полный путь к исходному файлу
    if not os.path.exists(absolute_image_path):
        logging.error(f"Изображение {absolute_image_path} не найдено.")
        return None
    
    filename = os.path.basename(image_path)  # Имя файла из пути
    destination_path = os.path.join(destination_folder, filename)  # Полный путь назначения
    
    if os.path.exists(destination_path):
        logging.info(f"Изображение {filename} уже существует в {destination_folder}.")
        return filename
    
    try:
        shutil.copy(absolute_image_path, destination_path)  # Копируем файл
        logging.info(f"Изображение {filename} скопировано в {destination_folder}.")
        return filename
    except IOError as e:
        logging.error(f"Ошибка при копировании изображения {image_path}: {e}")
        return None

# Инициализируем переменные для подключений к БД
k2_connection = None
jedig_connection = None

try:
    # Устанавливаем подключения к базам данных
    k2_connection = pymysql.connect(**k2_db_config)
    jedig_connection = pymysql.connect(**jedig_db_config)

    # Открываем курсоры для выполнения запросов
    with k2_connection.cursor() as k2_cursor, jedig_connection.cursor() as jedig_cursor:
        # Устанавливаем parent_id = 192 для привязки статей к категории с ID 14
        parent_id = 192  # Это ID записи в dyutb_assets для категории (com_content.category.14)

        # Извлекаем все материалы из K2
        k2_cursor.execute(f"""
            SELECT `id`, `title`, `alias`, `published`, `created`, `introtext`, `fulltext`, `hits`
            FROM `{k2_table_prefix}_items`
        """)
        k2_items = k2_cursor.fetchall()  # Получаем все записи в виде списка словарей

        # Обрабатываем каждый материал
        for item in k2_items:
            logging.info(f"Обработка материала с ID: {item['id']}, Заголовок: {item['title']}")

            # Обрезаем заголовок для dyutb_content (максимум 255 символов)
            original_title = item['title'] or ""  # Если title None, заменяем на пустую строку
            content_title = original_title[:255] if len(original_title) > 255 else original_title
            if len(original_title) > 255:
                logging.warning(f"Заголовок материала ID {item['id']} обрезан для content с {len(original_title)} до 255 символов: {content_title}")

            # Для dyutb_assets используем фиксированное значение "com_content" (как у старых материалов)
            assets_title = "com_content"

            # Обрабатываем основное изображение материала
            image_filename = generate_image_filename(item['id'])  # Генерируем имя файла
            absolute_image_path = os.path.join(k2_image_base_path, 'media/k2/items/cache/', image_filename)
            if os.path.exists(absolute_image_path):
                new_image_name = copy_image(f"media/k2/items/cache/{image_filename}", new_image_folder)
                if new_image_name:
                    item['image'] = f"{new_image_relative_path}{new_image_name}"
                    logging.info(f"Обновленное основное изображение: {item['image']}")
            else:
                logging.warning(f"Основное изображение для материала {item['id']} не найдено.")

            # Обрабатываем introtext (вступительный текст)
            updated_introtext = item['introtext'] or ""  # Если None, заменяем на пустую строку
            if updated_introtext:
                logging.info("Обработка introtext:")
                intro_images = extract_images(updated_introtext)  # Извлекаем пути к изображениям
                for image in intro_images:
                    logging.info(f"Найдено изображение: {image}")
                    new_image_name = copy_image(image, new_image_folder)  # Копируем изображение
                    if new_image_name:
                        updated_introtext = update_image_links(updated_introtext, image, f"{new_image_relative_path}{new_image_name}")
                logging.info("Обновленный introtext:")
                logging.info(updated_introtext)
                # Проверяем, остались ли старые ссылки
                if 'images/items/' in updated_introtext:
                    logging.warning(f"В introtext материала ID {item['id']} остались старые ссылки: {updated_introtext}")

            # Обрабатываем fulltext (полный текст)
            updated_fulltext = item['fulltext'] or ""  # Если None, заменяем на пустую строку
            if updated_fulltext:
                logging.info("Обработка fulltext:")
                fulltext_images = extract_images(updated_fulltext)
                for image in fulltext_images:
                    logging.info(f"Найдено изображение: {image}")
                    new_image_name = copy_image(image, new_image_folder)
                    if new_image_name:
                        updated_fulltext = update_image_links(updated_fulltext, image, f"{new_image_relative_path}{new_image_name}")
                logging.info("Обновленный fulltext:")
                logging.info(updated_fulltext)
                # Проверяем, остались ли старые ссылки
                if 'images/items/' in updated_fulltext:
                    logging.warning(f"В fulltext материала ID {item['id']} остались старые ссылки: {updated_fulltext}")

            # Проверяем, существует ли запись в dyutb_content по alias
            jedig_cursor.execute(f"SELECT `id`, `asset_id` FROM `{jedig_table_prefix}content` WHERE `alias` = %s", (item['alias'],))
            content_row = jedig_cursor.fetchone()

            # Общие параметры для вставки или обновления в dyutb_content
            common_params = (
                content_title,  # Заголовок (обрезанный до 255)
                updated_introtext,  # Обновлённый вступительный текст
                updated_fulltext,  # Обновлённый полный текст
                1,  # state = 1 (опубликовано)
                14,  # catid = 14 (ID категории в Joomla)
                item['created'],  # Дата создания из K2
                datetime.now(),  # Дата модификации (текущая)
                item.get('image', ''),  # Путь к основному изображению (или пусто)
                item.get('hits', 0),  # Количество просмотров (или 0)
                default_urls,  # Дополнительные ссылки
                default_attribs,  # Настройки отображения
                default_metadata,  # Мета-данные
                "",  # metadesc (пусто)
                "*",  # language (все языки)
                item['created'],  # Дата публикации
                1,  # access = 1 (общедоступно)
                489  # created_by = 489 (ID пользователя-автора)
            )

            # Если запись существует, обновляем её
            if content_row:
                content_id = content_row['id']
                asset_id = content_row['asset_id'] or 0
                jedig_cursor.execute(f"""
                    UPDATE `{jedig_table_prefix}content`
                    SET `title` = %s, `introtext` = %s, `fulltext` = %s, `state` = %s, `catid` = %s,
                        `created` = %s, `modified` = %s, `images` = %s, `hits` = %s, `urls` = %s,
                        `attribs` = %s, `metadata` = %s, `metadesc` = %s, `language` = %s, 
                        `publish_up` = %s, `access` = %s, `created_by` = %s, `version` = %s
                    WHERE `id` = %s
                """, common_params + (1, content_id))  # version = 1, затем ID записи
                logging.info(f"Обновлена запись в таблице content. ID: {content_id}")
            # Если записи нет, вставляем новую
            else:
                jedig_cursor.execute(f"""
                    INSERT INTO `{jedig_table_prefix}content` (
                        `title`, `alias`, `introtext`, `fulltext`, `state`, `catid`, `created`, `modified`,
                        `images`, `hits`, `urls`, `attribs`, `metadata`, `metadesc`, `language`, `asset_id`, 
                        `publish_up`, `access`, `created_by`, `version`
                    ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                """, (item['alias'],) + common_params + (1,))  # alias + параметры + version = 1
                content_id = jedig_cursor.lastrowid  # Получаем ID новой записи
                asset_id = 0
                logging.info(f"Добавлена запись в таблице content. ID: {content_id}")

            # Добавляем связь с рабочим процессом (workflow)
            jedig_cursor.execute(f"""
                INSERT IGNORE INTO `{jedig_table_prefix}workflow_associations` (`item_id`, `stage_id`, `extension`)
                VALUES (%s, %s, %s)
            """, (content_id, 1, 'com_content.article'))
            # IGNORE предотвращает дубликаты
            logging.info(f"Добавлена/обновлена запись в таблице workflow_associations для Content ID: {content_id}")

            # Обрабатываем запись в dyutb_assets
            asset_name = f"com_content.article.{content_id}"  # Уникальное имя ассета для статьи
            jedig_cursor.execute(f"SELECT `id` FROM `{jedig_table_prefix}assets` WHERE `name` = %s", (asset_name,))
            asset_row = jedig_cursor.fetchone()

            # Если запись в assets существует, обновляем её
            if asset_row:
                asset_id = asset_row['id']
                jedig_cursor.execute(f"""
                    UPDATE `{jedig_table_prefix}assets`
                    SET `parent_id` = %s, `lft` = %s, `rgt` = %s, `level` = %s, `name` = %s, `title` = %s, `rules` = %s
                    WHERE `id` = %s
                """, (parent_id, 0, 0, 1, asset_name, assets_title, default_rules, asset_id))
                # parent_id = 192 (категория), lft/rgt/level для вложенности (упрощённо 0/0/1)
                logging.info(f"Обновлена запись в таблице assets. ID: {asset_id}")
            # Если записи нет, вставляем новую
            else:
                jedig_cursor.execute(f"""
                    INSERT INTO `{jedig_table_prefix}assets` (`parent_id`, `lft`, `rgt`, `level`, `name`, `title`, `rules`)
                    VALUES (%s, %s, %s, %s, %s, %s, %s)
                """, (parent_id, 0, 0, 1, asset_name, assets_title, default_rules))
                asset_id = jedig_cursor.lastrowid
                logging.info(f"Добавлена запись в таблице assets. ID: {asset_id}")

            # Обновляем asset_id в dyutb_content, если он отличается
            if content_row is None or content_row['asset_id'] != asset_id:
                jedig_cursor.execute(f"""
                    UPDATE `{jedig_table_prefix}content`
                    SET `asset_id` = %s
                    WHERE `id` = %s
                """, (asset_id, content_id))
                logging.info(f"Обновлён asset_id в таблице content. Content ID: {content_id}, Asset ID: {asset_id}")

            # Фиксируем изменения в базе Joomla
            jedig_connection.commit()
            logging.info(f"Материал '{content_title}' успешно обработан в JEDIG.")
            logging.info("=" * 80)

except Exception as e:
    # Логируем любую ошибку, которая может возникнуть
    logging.error(f"Ошибка при выполнении скрипта: {e}")
finally:
    # Закрываем подключения к базам, если они были открыты
    if k2_connection:
        k2_connection.close()
    if jedig_connection:
        jedig_connection.close()