Миграция форума vBulletin 3 в Discourse через XenForo

Пишу это, пока всё свежо в памяти — сейчас это черновик, поэтому обязательно протестируйте и убедитесь, что он соответствует вашим требованиям.

Насколько я знаю, нет импортера из vB3 в Discourse, и у меня нет лицензий на vB4/5, для которых, как я полагаю, предназначены импортеры Discourse. Однако у меня есть лицензия на XenForo 1.4, и для неё существует импортер в Discourse! Для тех, у кого нет лицензии XF, их часто можно купить на вторичном рынке или же попросить/заплатить кому-то выполнить импорт и передать вам базу данных XF.

Ранее я уже делал импорт из vB3.6 в XF, так что знаю, что это работает отлично (единственное, что не импортируется — это фотографии профиля, так как XF использует только аватары, но у меня есть решение для этого).

Итак…

Сначала импортируйте ваш форум vB в XF, как обычно.

Я рекомендую оставить ваш форум vB живым и доступным в интернете (поэтому делайте импорт в XF в подкаталог). Это страховка на случай, если вы позже решите сохранить форум vB, а также потому, что мы будем копировать фотографии профиля с живого сайта (хотя вы можете использовать скрипт копирования профиля ниже заранее, если это действительно необходимо).

После того как вы убедитесь, что форум успешно конвертирован в XF, сделайте резервную копию этой новой базы данных и скопируйте её на вашу машину для разработки.

Моя машина для разработки — Mac, поэтому эти инструкции для macOS.

brew install mysql 
// Также убедитесь, что он запущен

mysql -u root

create database xenforo_db;
exit;

mysql -u root -p xenforo_db < /path/to/your/backup/and/downloaded/xenforo_db.sql

Настройте среду разработки Discourse как обычно (см. это для macOS), затем:

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

bundle
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=development bundle exec rake admin:create 
RAILS_ENV=development bundle exec rake admin:create

Для первой учётной записи администратора попробуйте использовать тот же адрес электронной почты, что и у вашего существующего администратора на вашей установке vB/XF. Выберите «Y», когда система спросит, хотите ли вы предоставить ему права администратора.

Для второго создания учётной записи укажите адрес вроде guest@something.com и выберите «n», когда система спросит, создавать ли её как учётную запись администратора. Нам нужна эта учётная запись для сообщений, связанных с гостями/удалёнными пользователями. Вы можете зайти в rails c и затем выполнить User.last, чтобы проверить её ID, но скорее всего это будет 2. Мы добавим это в скрипт импорта.

Я внес несколько изменений в скрипт импорта, вот моя версия скрипта (замените содержимое script/import_scripts/xenforo.rb на него):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # может быть, не нужно — не помню, зачем я это добавил
require "htmlentities" # может быть, не нужно — не помню, зачем я это добавил

require File.expand_path(File.dirname(__FILE__) + "/base.rb")

# Вызовите так:
#   RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb
class ImportScripts::XenForo < ImportScripts::Base

  XENFORO_DB = "xenforo_db_3"
  TABLE_PREFIX = "xf_"
  BATCH_SIZE = 1000
  ATTACHMENT_DIR = '/full/path/to/attachments/eg/name/projects/discourse/sitename/discourse/tmp/attachments'
  AVATAR_DIR = '/full/path/to/avatars/eg/name/projects/discourse/sitename/discourse/tmp/avatars'
  PROFILE_PIC_DIR = '/full/path/to/profilepics/eg/name/projects/discourse/sitename/discourse/tmp/profilepics'

  def initialize
    super
    @client = Mysql2::Client.new(
      host: "localhost",
      username: "root",
      password: "",
      database: XENFORO_DB
    )

    @category_mappings = {}
    @prefix_as_category = false
  end

  def execute
    import_users
    import_avatars
    import_categories
    import_posts
  end

  def import_users
    puts '', "создание пользователей"

    total_count = mysql_query("SELECT count(*) count FROM #{TABLE_PREFIX}user;").first['count']

    batches(BATCH_SIZE) do |offset|
      results = mysql_query(
        "SELECT user_id id, username, email, custom_title title, register_date created_at,
                last_activity last_visit_time, user_group_id, is_moderator, is_admin, is_staff
         FROM #{TABLE_PREFIX}user
         LIMIT #{BATCH_SIZE}
         OFFSET #{offset};")

      break if results.size < 1

      next if all_records_exist? :users, results.map { |u| u["id"].to_i }

      create_users(results, total: total_count, offset: offset) do |user|
        next if user['username'].blank?
        { id: user['id'],
          email: user['email'],
          username: user['username'],
          title: user['title'],
          created_at: Time.zone.at(user['created_at']),
          last_seen_at: Time.zone.at(user['last_visit_time']),
          moderator: user['is_moderator'] == 1 || user['is_staff'] == 1,
          admin: user['is_admin'] == 1 }
      end
    end
  end

  def import_user_profiles
    puts "Импорт профилей пользователей..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Импорт профилей: получение информации"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Обновление профиля для #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "импорт категорий..."

    categories = mysql_query("
        SELECT node_id id,
               title,
               description,
               parent_node_id,
               display_order
          FROM #{TABLE_PREFIX}node
      ORDER BY parent_node_id, display_order
      ").to_a

    top_level_categories = categories.select { |c| c["parent_node_id"] == 0 }

    create_categories(top_level_categories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order']
      }
    end

    top_level_category_ids = Set.new(top_level_categories.map { |c| c["id"] })

    subcategories = categories.select { |c| top_level_category_ids.include?(c["parent_node_id"]) }

    create_categories(subcategories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order'],
        parent_category_id: category_id_from_imported_category_id(c['parent_node_id'])
      }
    end

    subcategory_ids = Set.new(subcategories.map { |c| c['id'] })

    # более глубокие категории должны стать тегами
    categories.each do |c|
      next if c['parent_node_id'] == 0
      next if top_level_category_ids.include?(c['id'])
      next if subcategory_ids.include?(c['id'])

      # Найдите подкатегорию для тем в этой категории
      parent = c
      while !parent.nil? && !subcategory_ids.include?(parent['id'])
        parent = categories.find { |subcat| subcat['id'] == parent['parent_node_id'] }
      end

      if parent
        tag_name = DiscourseTagging.clean_tag(c['title'])
        @category_mappings[c['id']] = {
          category_id: category_id_from_imported_category_id(parent['id']),
          tag: Tag.find_by_name(tag_name) || Tag.create(name: tag_name)
        }
      else
        puts '', "Не удалось найти категорию для #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # Этот метод является альтернативой import_categories.
  # Он использует префиксы вместо узлов.
  def import_categories_from_thread_prefixes
    puts "", "импорт категорий..."

    categories = mysql_query("
                              SELECT prefix_id id
                              FROM #{TABLE_PREFIX}thread_prefix
                              ORDER BY prefix_id ASC
                            ").to_a

    create_categories(categories) do |category|
      {
        id: category["id"],
        name: "Category-#{category["id"]}"
      }
    end

    @prefix_as_category = true
  end

  def import_posts
    puts "", "создание тем и сообщений"

    total_count = mysql_query("SELECT count(*) count from #{TABLE_PREFIX}post").first["count"]

    posts_sql = "
        SELECT p.post_id id,
               t.thread_id topic_id,
               #{@prefix_as_category ? 't.prefix_id' : 't.node_id'} category_id,
               t.title title,
               t.first_post_id first_post_id,
               p.user_id user_id,
               p.message raw,
               p.post_date created_at
        FROM #{TABLE_PREFIX}post p,
             #{TABLE_PREFIX}thread t
        WHERE p.thread_id = t.thread_id
        AND p.message_state = 'visible'
        AND t.discussion_state = 'visible'
        ORDER BY p.post_date
        LIMIT #{BATCH_SIZE}" # нужен OFFSET

    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a

      break if results.size < 1
      next if all_records_exist? :posts, results.map { |p| p['id'] }

      create_posts(results, total: total_count, offset: offset) do |m|
        skip = false
        mapped = {}

        mapped[:id] = m['id']
        mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || 2
        mapped[:raw] = process_xenforo_post(m['raw'], m['id'])
        mapped[:created_at] = Time.zone.at(m['created_at'])

        if m['id'] == m['first_post_id']
          if m['category_id'].to_i == 0 || m['category_id'].nil?
            mapped[:category] = SiteSetting.uncategorized_category_id
          else
            mapped[:category] = category_id_from_imported_category_id(m['category_id'].to_i) ||
              @category_mappings[m['category_id']].try(:[], :category_id)
          end
          mapped[:title] = CGI.unescapeHTML(m['title'])
        else
          parent = topic_lookup_from_imported_post_id(m['first_post_id'])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Родительское сообщение #{m['first_post_id']} не существует. Пропуск #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # Применение тегов
    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a
      break if results.size < 1

      results.each do |m|
        next unless m['id'] == m['first_post_id'] && m['category_id'].to_i > 0
        next unless tag = @category_mappings[m['category_id']].try(:[], :tag)
        next unless topic_mapping = topic_lookup_from_imported_post_id(m['id'])

        topic = Topic.find_by_id(topic_mapping[:topic_id])

        topic.tags = [tag] if topic
      end
    end

  end
  
  def process_xenforo_post(raw, import_id)
    s = raw.dup

    # :) кодируется как <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
    s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')

    # Некоторые ссылки выглядят так: <!-- m --><a class="postlink" href="http://www.onegameamonth.com">http://www.onegameamonth.com</a><!-- m -->
    s.gsub!(/<!-- \w --><a(?:.+)href="(\S+)"(?:.*)>(.+)<\/a><!-- \w -->/, '[\2](\1)')

    # Многие теги bbcode phpbb имеют прикреплённый хеш. Примеры:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]нажмите здесь[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Некоторый текст.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Удалить теги видео mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB сокращает текст ссылки так, что это ломает нашу обработку markdown:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # Исправление ошибки: xenforo.rb: 160: in `gsub!': invalid byte sequence in UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # Пока обходим это:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

    # [QUOTE]...[/QUOTE]
    s.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" }

    # Вложенные цитаты
    s.gsub!(/(\[\/?QUOTE.*?\])/mi) { |q| "\n#{q}\n" }

    # [QUOTE="username, post: 28662, member: 1283"]
    s.gsub!(/\[quote="(\w+), post: (\d*), member: (\d*)"\]/i) do
      username, imported_post_id, _imported_user_id = $1, $2, $3

      topic_mapping = topic_lookup_from_imported_post_id(imported_post_id)

      if topic_mapping
        "\n[quote=\"#{username}, post:#{topic_mapping[:post_number]}, topic:#{topic_mapping[:topic_id]}\"]\n"
      else
        "\n[quote=\"#{username}\"]\n"
      end
    end

    # [URL=...]...[/URL]
    s.gsub!(/\[url="?(.+?)"?\](.+)\[\/url\]/i) { "[#{$2}](#{$1})" }

    # [IMG]...[/IMG]
    s.gsub!(/\[\/?img\]/i, "")

    # преобразовать теги списка в ul и теги list=1 в ol
    # (по сути, здесь не хватает только list=a...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # преобразовать *-теги в li-теги, чтобы bbcode-to-md мог сделать свою магию со списками phpBB:
    s.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]')

    # [YOUTUBE]<id>[/YOUTUBE]
    s.gsub!(/\[youtube\](.+?)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [youtube=425,350]id[/youtube]
    s.gsub!(/\[youtube="?(.+?)"?\](.+)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$2}\n" }

    # [MEDIA=youtube]id[/MEDIA]
    s.gsub!(/\[MEDIA=youtube\](.+?)\[\/MEDIA\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [ame="youtube_link"]title[/ame]
    s.gsub!(/\[ame="?(.+?)"?\](.+)\[\/ame\]/i) { "\n#{$1}\n" }

    # [VIDEO=youtube;<id>]...[/VIDEO]
    s.gsub!(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [USER=706]@username[/USER]
    s.gsub!(/\[user="?(.+?)"?\](.+)\[\/user\]/i) { $2 }

    # Удалить тег цвета
    s.gsub!(/\[color=[#a-z0-9]+\]/i, "")
    s.gsub!(/\[\/color\]/i, "")

    if Dir.exist? ATTACHMENT_DIR
      s = process_xf_attachments(:gallery, s)
      s = process_xf_attachments(:attachment, s)
    end

    s
  end

  def process_xf_attachments(xf_type, s)
    ids = Set.new
    ids.merge(s.scan(get_xf_regexp(xf_type)).map { |x| x[0].to_i })
    ids.each do |id|
      next unless id
      sql = get_xf_sql(xf_type, id).squish!
      results = mysql_query(sql)
      if results.size < 1
        # Удалить вложение
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} не найден в исходной базе данных. Удаляем."
        next
      end
      original_filename = results.first['filename']
      result = results.first
      upload = import_xf_attachment(result['data_id'], result['file_hash'], result['user_id'], original_filename)
      next unless upload
      if upload.present? && upload.persisted?
        s.gsub!(get_xf_regexp(xf_type, id), @uploader.html_for_upload(upload, original_filename))
      else
        STDERR.puts "Не удалось найти загрузку: #{upload.id}. Пропуск вложения id #{id}"
      end
    end
    s
  end

  def import_xf_attachment(data_id, file_hash, owner_id, original_filename)
    current_filename = "#{data_id}-#{file_hash}.data"
    path = Pathname.new(ATTACHMENT_DIR + "/#{data_id / 1000}/#{current_filename}")
    new_path = path.dirname + original_filename
    upload = nil
    if File.exist? path
      FileUtils.cp path, new_path
      upload = @uploader.create_upload owner_id, new_path, original_filename
      FileUtils.rm new_path
    else
      STDERR.puts "Не удалось найти файл #{path}. Пропуск вложения id #{data_id}"
    end
    upload
  end

  def get_xf_regexp(type, id = nil)
    case type
    when :gallery
      Regexp.new(/\[GALLERY=media,\s#{id ? id : '(\d+)'}\].+?\]/i)
    when :attachment
      Regexp.new(/\[ATTACH(?>=\w+)?\]#{id ? id : '(\d+)'}\[\/ATTACH\]/i)
    end
  end

  def get_xf_sql(type, id)
    case type
    when :gallery
      <<-SQL
		SELECT m.media_id, m.media_title, a.attachment_id, a.data_id, d.filename, d.file_hash,d.user_id
		FROM xengallery_media as m
		INNER JOIN #{TABLE_PREFIX}attachment a on m.attachment_id = a.attachment_id
		INNER JOIN #{TABLE_PREFIX}attachment_data d on a.data_id = d.data_id
		WHERE media_id = #{id}
      SQL
    when :attachment
      <<-SQL
		SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
		FROM #{TABLE_PREFIX}attachment AS a
		INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
		WHERE attachment_id = #{id}
      SQL
    end
  end

  def mysql_query(sql)
    @client.query(sql, cache_rows: false)
  end
  
  def import_avatars
    if AVATAR_DIR
      users = User.all
      users.each do |u|
        unless u.custom_fields["import_id"].nil?
          import_id = u.custom_fields["import_id"]
          if import_id.to_i < 1000
            dir_num = "0"
          elsif import_id.to_i > 1000
            dir_num = import_id.first
          end
        
          filename = "#{import_id}.jpg"
          avatar_file_path = "#{AVATAR_DIR}/l/#{dir_num}"
          avatar_file_path_and_name = "#{avatar_file_path}/#{filename}"
          profile_pic_file_path_and_name = "#{PROFILE_PIC_DIR}/#{filename}"
          
          if File.exists?(profile_pic_file_path_and_name)
            upload_pic_or_avatar(u, profile_pic_file_path_and_name, filename)
          elsif File.exists?(avatar_file_path_and_name)
            upload_pic_or_avatar(u, avatar_file_path_and_name, filename)
          end
        end
      end
    end
  end
  
  def upload_pic_or_avatar(u, file_path_and_name, filename)
    upload = create_upload(u.id, file_path_and_name, filename)
    if upload.persisted?
      puts "загрузка сохранена"
      u.import_mode = false
      u.create_user_avatar
      u.import_mode = true
      u.user_avatar.update(custom_upload_id: upload.id)
      u.update(uploaded_avatar_id: upload.id)
    else
      puts "Ошибка: Загрузка не была сохранена для #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

Примечания:

  • Добавлен шаг/метод import_avatars (это должны быть jpg)
  • Добавлены пути к аватарам и фотографиям профиля
  • Добавлен ID вновь созданной гостевой учётной записи как запасной вариант, когда пользователь не существует, но сообщение есть (гостевые пользователи)

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

Копировщик фотографий профиля:

Сначала установите Down с помощью gem install down

Затем создайте новый файл с содержимым:

require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Получение пользователя #{u}"
  puts ""
  profile_pic_url = "https://www.forum-name.com/image.php?u=#{u}&type=profile"
  destination = "/full/path/where/you/want/to/save/profile/pics/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Завершено #{u}"
  rescue
    puts "Не удалось #{u}"
  end
  puts ""
end

Примечания:

  • Предполагается, что все фотографии профиля (и аватары) имеют формат jpg. К счастью, мы разрешали только jpg для аватаров и фотографий профиля, так что это работает для нас.
  • Убедитесь, что ваши пути и URL верны, и что профили и фотографии профиля видны гостям.
  • Замените NUMBER_OF_USERS на количество пользователей, которое у вас есть (например, 3872).

Затем запустите скрипт в терминале, выполнив ruby /path/to/name-of-script.rb в терминале. Это скопирует все ваши фотографии профиля в этот каталог, затем просто перейдите туда, отсортируйте по размеру файла и удалите все пустые файлы (их будет много, так как не все загружают фотографии профиля).

Выполнение импорта:

Когда всё вышеперечисленное сделано, вы готовы начать :smiley:

RAILS_ENV=development bundle exec ruby script/import_scripts/xenforo.rb

Импорт форума с 100K сообщений и несколькими тысячами участников занимает около 90 минут, и мои первоначальные тесты показывают, что всё работает отлично. Однако..

ПРИМЕЧАНИЯ:

  • Импортируется только текст location и about из профилей
  • Я не проверял загрузку вложений, так как на тестовом форуме, который я использую для этих тестов, загрузка вложений никогда не разрешалась. На одном из форумов, которые я хочу импортировать (он намного больше, поэтому я использую этот меньший форум для тестов), загрузка вложений разрешена, поэтому я сообщу о результатах.
  • Импорт, выполненный на машине для разработки, был успешно перенесён/восстановлен в работающую продакшн-установку, и всё прошло хорошо :+1:
  • (Всё ещё нужно протестировать это на большем форуме с вложениями — обновлю этот пост, когда это будет сделано)

Опубликовал это сейчас, так как думаю, что несколько человек пытаются импортировать свои форумы vB3 в Discourse прямо сейчас.

5 лайков