Migra un foro de vBulletin 3 a Discourse a través de XenForo

Escribo esto mientras está fresco en mi mente: actualmente es un trabajo en progreso, así que asegúrate de probarlo y verificar que cumpla con tus necesidades.

Que yo sepa, no existe un importador de vB3 a Discourse, y no tengo licencias de vB4/5 (que creo que son las que utilizan los importadores de Discourse), pero sí tengo una licencia de XenForo 1.4 y ¡sí existe un importador de Discourse para eso! Para quienes no tengan una licencia de XF, a menudo pueden comprarlas en el mercado de segunda mano, o bien pueden pedirle a alguien que realice la importación y les proporcione la base de datos de XF.

Ya he realizado una importación de vB3.6 a XF anteriormente, así que sé que funciona bien (lo único que no importa son las fotos de perfil, ya que XF solo tiene avatares, pero tengo una solución para eso).

Bien…

Primero, importa tu foro vB a XF como lo harías normalmente.

Recomiendo mantener tu foro vB en línea y accesible en internet (así que realiza la importación a XF en un subdirectorio). Esto es solo una medida de seguridad por si más adelante decides que quieres conservar el foro vB, y también porque copiaremos las fotos de perfil desde el sitio en vivo (aunque puedes usar el script de copiador de perfiles que aparece más adelante si realmente lo necesitas).

Una vez que hayas verificado que el foro se ha convertido correctamente a XF, haz una copia de seguridad de esta nueva base de datos y cópiala a tu máquina de desarrollo.

Mi máquina de desarrollo es un Mac, así que estas instrucciones son para macOS.

brew install mysql
// Asegúrate también de que esté iniciado

mysql -u root

create database xenforo_db;
exit;

mysql -u root -p xenforo_db < /ruta/a/tu/respaldo-y-descargado/xenforo_db.sql

Configura tu entorno de desarrollo de Discourse como de costumbre (consulta esta guía para macOS) y luego:

Abre database.yml y cambia el nombre de la base de datos a algo como discourse_development_sitename_01: usa números para que puedas repetir la importación varias veces simplemente cambiando el 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 la primera cuenta de administrador, intenta usar la misma dirección de correo electrónico que tu cuenta de administrador existente en tu instalación de vB/XF. Selecciona «Sí» cuando te pregunte si quieres otorgarle permisos de administrador.

Para la segunda creación de cuenta, usa una dirección de correo como guest@algo.com y selecciona «No» cuando te pregunte si quieres crearla como cuenta de administrador. Necesitamos esta cuenta para las publicaciones asociadas con invitados o usuarios eliminados. Puedes entrar a rails c y luego ejecutar User.last para verificar su ID, pero probablemente sea 2. Lo añadiremos al script de importación.

He realizado algunos cambios al script de importación; aquí está mi versión del script (reemplaza el contenido de script/import_scripts/xenforo.rb con esto):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # puede que no sea necesario, no recuerdo por qué lo añadí
require "htmlentities" # puede que no sea necesario, no recuerdo por qué lo añadí

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

# Llámalo así:
#   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 = '/ruta/completa/a/adjuntos/eg/nombre/proyectos/discourse/sitename/discourse/tmp/attachments'
  AVATAR_DIR = '/ruta/completa/a/avatars/eg/nombre/proyectos/discourse/sitename/discourse/tmp/avatars'
  PROFILE_PIC_DIR = '/ruta/completa/a/fotos_perfil/eg/nombre/proyectos/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 '', "creando usuarios"

    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 perfiles de usuario..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importando perfiles: obteniendo información"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Actualizando 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 categorías..."

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

    # las categorías más profundas necesitan ser etiquetas
    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'])

      # Buscar una subcategoría para los temas en esta categoría
      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 '', "¡No se pudo encontrar una categoría para #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # Este método es una alternativa a import_categories.
  # Utiliza prefijos en lugar de nodos.
  def import_categories_from_thread_prefixes
    puts "", "importando categorías..."

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

    @prefix_as_category = true
  end

  def import_posts
    puts "", "creando temas y publicaciones"

    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}" # necesita 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 "La publicación padre #{m['first_post_id']} no existe. Saltando #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

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

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

    # Algunos enlaces se ven así: <!-- 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)')

    # Muchas etiquetas bbcode de phpbb tienen un hash adjunto. Ejemplos:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]haz clic aquí[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Algun texto.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Eliminar etiquetas de video de mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB acorta el texto de los enlaces así, lo cual rompe nuestro procesamiento de markdown:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # Solución para el error: xenforo.rb: 160: en `gsub!': secuencia de bytes inválida en UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # Solución temporal:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

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

    # Citas anidadas
    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, "")

    # convertir etiquetas de lista a ul y etiquetas list=1 a ol
    # (básicamente, solo nos falta list=a aquí...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # convertir etiquetas *-tags a li-tags para que bbcode-to-md haga su magia en las listas de 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]@usuario[/USER]
    s.gsub!(/\[user="?(.+?)"?\](.+)\[\/user\]/i) { $2 }

    # Eliminar la etiqueta de color
    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
        # Eliminar adjunto
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} no encontrado en la base de datos de origen. Eliminando."
        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 "No se pudo encontrar la subida: #{upload.id}. Saltando el adjunto 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 "No se pudo encontrar el archivo #{path}. Saltando el adjunto 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 "subida persistida"
      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 "Error: La subida no se persistió para #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

Notas:

  • Añade un paso/método import_avatars (estas deben ser imágenes jpg)
  • Añadimos rutas a avatares y fotos de perfil
  • Añadimos el ID del usuario invitado recién creado como respaldo cuando un usuario no existe pero sí una publicación (usuarios invitados)

Ahora copiemos las fotos de perfil para usarlas como avatares si existen; si no, se usará el avatar del usuario si ha subido uno. Puedes omitir esto si solo quieres una importación directa de avatares a avatares.

Copiador de fotos de perfil:

Primero, instala Down con gem install down

Luego crea un nuevo archivo con:

require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Obteniendo usuario #{u}"
  puts ""
  profile_pic_url = "https://www.nombre-foro.com/image.php?u=#{u}&type=profile"
  destination = "/ruta/completa/donde/quieres/guardar/fotos_perfil/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Completado #{u}"
  rescue
    puts "Fallido #{u}"
  end
  puts ""
end

Notas:

  • Asume que todas las fotos de perfil (y avatares) son jpg. Afortunadamente, solo hemos permitido jpg como avatares y fotos de perfil, así que esto funciona para nosotros.
  • Asegúrate de que tus rutas y URL sean correctas y que los perfiles y las fotos de perfil sean visibles para los invitados.
  • Reemplaza NUMBER_OF_USERS con el número de usuarios que tienes (por ejemplo, 3872).

Luego ejecuta el script en la terminal con ruby /ruta/a/nombre-del-script.rb en la terminal. Esto copiará todas tus fotos de perfil a ese directorio; luego simplemente ve allí, ordena por tamaño de archivo y elimina cualquier archivo vacío (habrá muchos, ya que no todos suben una foto de perfil).

Realizando la importación:

Una vez hecho todo lo anterior, estás listo para comenzar :smiley:

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

Tarda aproximadamente 90 minutos en importar un foro con 100K publicaciones y unos pocos miles de miembros, y mis pruebas iniciales parecen indicar que funciona bien. Sin embargo…

NOTAS:

  • Solo importará el texto de location y about de los perfiles
  • No he verificado las subidas de archivos adjuntos ya que nunca hemos permitido subir archivos adjuntos en el foro de prueba que estoy utilizando para estas pruebas. Hemos estado en uno de los foros que quiero importar (es mucho más grande, de ahí el uso de este foro más pequeño para pruebas), así que informaré sobre cómo va eso.
  • La importación realizada en la máquina de desarrollo ahora se ha movido/restaurado con éxito a una instalación de producción en vivo y todo salió bien :+1:
  • (Todavía necesito probar esto en un foro más grande con archivos adjuntos; actualizaré esta publicación cuando se haya hecho)

Publico esto ahora porque creo que varias personas están intentando importar sus foros vB3 a Discourse en este momento.

5 Me gusta