Migrar um fórum vBulletin 3 para Discourse via XenForo

Escrevendo isso enquanto está fresco na minha mente — atualmente é um trabalho em andamento, então certifique-se de testá-lo e garantir que atenda às suas necessidades.

Não há um importador de vB3 para Discourse, pelo que sei, e não tenho nenhuma licença vB4/5, que acredito serem as que os importadores do Discourse utilizam — mas eu tenho uma licença do XenForo 1.4 e existe um importador do Discourse para ele! Para aqueles que não possuem uma licença do XF, vocês geralmente podem comprá-las no mercado de usados, ou, podem pedir/pagar alguém para fazer a importação para vocês e fornecer o banco de dados do XF.

Já fiz uma importação de vB3.6 para XF anteriormente, então sei que funciona bem (a única coisa que não é importada são as fotos de perfil, pois o XF só possui avatares — mas tenho uma correção para isso).

Ok…

Primeiro, importe seu fórum vB para o XF como você normalmente faria.

Recomendo que você mantenha seu fórum vB ao vivo e acessível na internet (então faça a importação para o XF em um subdiretório). Isso é apenas uma medida de segurança caso você decida mais tarde que quer manter o fórum vB e também porque estaremos copiando as fotos de perfil do site ao vivo (embora você possa usar o script de copiador de perfil abaixo antes, se realmente precisar).

Uma vez que você tenha verificado que o fórum foi convertido com sucesso para o XF, faça um backup deste novo banco de dados e copie-o para sua máquina de desenvolvimento.

Minha máquina de desenvolvimento é um Mac, então estas instruções são para macOS.

brew install mysql
// Certifique-se também de que ele esteja iniciado

mysql -u root

create database xenforo_db;
exit;

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

Configure seu ambiente de desenvolvimento do Discourse como de costume (veja este para macOS) e então:

Abra database.yml e altere o nome do banco de dados para algo como discourse_development_sitename_01 — use números para que você possa refazer a importação algumas vezes simplesmente alterando o número.

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

Para a primeira conta de administrador, tente usar o mesmo endereço de e-mail que sua conta de administrador existente na sua instalação vB/XF. Selecione ‘S’ quando perguntar se você deseja dar permissões de administrador.

Para a segunda passagem de criação de conta, faça o e-mail algo como guest@something.com e selecione ‘N’ quando perguntar se deseja criá-la como conta de administrador. Precisamos desta conta para postagens associadas a convidados/usuários excluídos. Você pode entrar em rails c e depois User.last para verificar seu ID, mas provavelmente será 2. Vamos adicionar isso ao script de importação.

Fiz algumas alterações no script de importação, aqui está minha versão do script (substitua o conteúdo de script/import_scripts/xenforo.rb por ele):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # talvez não seja necessário - não me lembro por que eu adicionei agora
require "htmlentities" # talvez não seja necessário - não me lembro por que eu adicionei agora

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

# Chame assim:
#   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 '', "criando usuários"

    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 "Importando perfis de usuários..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importando perfis: buscando informações"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Atualizando perfil para #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "importando categorias..."

    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'] })

    # categorias mais profundas precisam ser tags
    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'])

      # Encontre uma subcategoria para tópicos nesta categoria
      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 '', "Não foi possível encontrar uma categoria para #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # Este método é uma alternativa a import_categories.
  # Ele usa prefixos em vez de nós.
  def import_categories_from_thread_prefixes
    puts "", "importando categorias..."

    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: "Categoria-#{category["id"]}"
      }
    end

    @prefix_as_category = true
  end

  def import_posts
    puts "", "criando tópicos e postagens"

    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}" # precisa de 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 "Postagem pai #{m['first_post_id']} não existe. Ignorando #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # Aplicar tags
    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

    # :) é codificado como <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
    s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')

    # Alguns links se parecem com isso: <!-- 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)')

    # Muitas tags bbcode do phpBB têm um hash anexado a elas. Exemplos:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]clique aqui[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Algum texto.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Remover tags de vídeo do mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB encurta o texto do link assim, o que quebra nosso processamento markdown:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    #Correção para o erro: 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

    # Contornar por enquanto:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

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

    # Citações aninhadas
    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, "")

    # converter tags de lista para ul e tags list=1 para ol
    # (basicamente, só estamos faltando list=a aqui...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # converter tags *- para tags li para que bbcode-to-md possa fazer sua mágica nas listas do 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"]título[/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 }

    # Remover a tag de cor
    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
        # Remover anexo
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} não encontrado no banco de dados de origem. Removendo."
        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 "Não foi possível encontrar upload: #{upload.id}. Ignorando anexo 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 "Não foi possível encontrar arquivo #{path}. Ignorando anexo 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 "upload persistido"
      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 "Erro: Upload não foi persistido para #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

Notas:

  • Adiciona um passo/método import_avatars (estes devem ser jpgs)
  • Adicionamos caminhos para avatares e fotos de perfil
  • Adicionamos o ID do usuário convidado recém-criado como fallback quando um usuário não existe, mas uma postagem existe (usuários convidados)

Agora vamos copiar as fotos de perfil para usar como avatares se existirem — se não existirem, o avatar do usuário será usado se ele tiver feito upload de um. Você pode pular isso se quiser apenas uma importação direta de avatares para avatares.

Copiador de foto de perfil:

Primeiro, instale o Down com gem install down

Em seguida, crie um novo arquivo com:

require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Buscando usuário #{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 "Concluído #{u}"
  rescue
    puts "Falhou #{u}"
  end
  puts ""
end

Notas:

  • Assume que todas as fotos de perfil (e avatares) são jpgs. Felizmente, só permitimos jpgs como avatares e fotos de perfil, então isso funciona para nós.
  • Certifique-se de que seus caminhos e URL estão corretos e que perfis e fotos de perfil são visíveis para convidados.
  • Substitua NUMBER_OF_USERS pelo número de usuários que você tem (ex: 3872).

Em seguida, execute o script no terminal executando ruby /path/to/name-of-script.rb no terminal. Isso copiará todas as suas fotos de perfil para aquele diretório, depois basta ir até ele, ordenar por tamanho de arquivo e excluir qualquer arquivo vazio (haverá muitos — pois nem todos fazem upload de uma foto de perfil).

Fazendo a importação:

Uma vez que tudo acima esteja feito, você está pronto para começar :smiley:

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

Leva cerca de 90 minutos para importar um fórum com 100 mil postagens e alguns milhares de membros, e meus testes iniciais parecem mostrar que está funcionando bem. No entanto..

NOTAS:

  • Só importará o texto location e about dos perfis
  • Não verifiquei uploads de anexos, pois nunca permitimos uploads de anexos no fórum de teste que estou usando para estes testes. Temos estado em um dos fóruns que quero importar (é muito maior, por isso estou usando este fórum menor para testes), então voltarei com notícias sobre como isso correu.
  • A importação feita na máquina de desenvolvimento foi agora movida com sucesso/‘restaurada’ para uma instalação de produção ao vivo e tudo correu bem :+1:
  • (Ainda preciso testar isso em um fórum maior com anexos — atualizarei esta postagem quando isso for feito)

Publicando isso agora, pois acho que algumas pessoas estão tentando importar seus fóruns vB3 para o Discourse atualmente.

5 curtidas