Migrare un forum vBulletin 3 a Discourse tramite XenForo

Sto scrivendo questo mentre è fresco nella mia mente - è attualmente in fase di sviluppo, quindi assicurati di testarlo e verificare che soddisfi le tue esigenze.

Per quanto ne sappia, non esiste un importatore da vB3 a Discourse, e non ho licenze vB4/5 per cui credo siano destinati gli importatori di Discourse. Tuttavia, ho una licenza XenForo 1.4 e esiste un importatore per quello! Per chi non ha una licenza XF, spesso è possibile acquistarle sul mercato dell’usato, oppure puoi chiedere a qualcuno di eseguire l’importazione per te e fornirti il database XF.

Ho già eseguito un’importazione da vB3.6 a XF, quindi so che funziona bene (l’unica cosa che non viene importata sono le foto del profilo, poiché XF ha solo avatar, ma ho una soluzione per questo).

Ok…

Importa prima il tuo forum vB in XF come faresti normalmente.

Consiglio di mantenere il tuo forum vB attivo e accessibile su Internet (quindi esegui l’importazione in XF in una sottodirectory). Questo è solo un sistema di sicurezza nel caso in cui in seguito decidessi di voler mantenere il forum vB, e anche perché copieremo le foto del profilo dal sito attivo (anche se puoi utilizzare lo script di copia del profilo sottostante in precedenza se ne hai davvero bisogno).

Una volta verificato che il forum sia stato convertito con successo in XF, crea un backup di questo nuovo database e copialo sulla tua macchina di sviluppo.

La mia macchina di sviluppo è un Mac, quindi queste istruzioni sono per macOS.

brew install mysql
// Assicurati anche che sia avviato

mysql -u root

create database xenforo_db;
exit;

mysql -u root -p xenforo_db < /percorso/del/tuo/backup/e/downloadato/xenforo_db.sql

Configura il tuo ambiente di sviluppo di Discourse come al solito (vedi questo per macOS) poi:

Apri database.yml e cambia il nome del database in qualcosa come discourse_development_nomesito_01 - usa numeri in modo da poter ripetere l’importazione più volte semplicemente cambiando il numero.

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

Per il primo account amministratore, cerca di utilizzare lo stesso indirizzo email del tuo account amministratore esistente sulla tua installazione vB/XF. Seleziona ‘S’ quando ti chiede se vuoi assegnargli i permessi di amministratore.

Per la seconda creazione di un account, usa un’email tipo ospite@qualcosa.com e seleziona ‘n’ quando ti chiede di crearlo come account amministratore. Abbiamo bisogno di questo account per i post associati agli ospiti/utenti eliminati. Puoi entrare in rails c e poi User.last per controllare il suo ID, ma sarà probabilmente 2. Lo aggiungeremo allo script di importazione.

Ho apportato alcune modifiche allo script di importazione, ecco la mia versione dello script (sostituisci il contenuto di script/import_scripts/xenforo.rb con questo):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # potrebbe non essere necessario - non ricordo perché l'ho aggiunto ora
require "htmlentities" # potrebbe non essere necessario - non ricordo perché l'ho aggiunto ora

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

# Chiamalo così:
#   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 = '/percorso/completo/allegati/es/nome/progetti/discourse/nomesito/discourse/tmp/allegati'
  AVATAR_DIR = '/percorso/completo/avatar/es/nome/progetti/discourse/nomesito/discourse/tmp/avatar'
  PROFILE_PIC_DIR = '/percorso/completo/fotoprofilo/es/nome/progetti/discourse/nomesito/discourse/tmp/fotoprofilo'

  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 '', "creazione utenti"

    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 "Importazione profili utente..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importazione profili: recupero informazioni"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Aggiornamento profilo per #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "importazione categorie..."

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

    # le categorie più profonde devono essere tag
    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'])

      # Trova una sottocategoria per gli argomenti in questa 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 '', "Impossibile trovare una categoria per #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # Questo metodo è un'alternativa a import_categories.
  # Utilizza i prefissi invece dei nodi.
  def import_categories_from_thread_prefixes
    puts "", "importazione categorie..."

    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 "", "creazione argomenti e post"

    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}" # richiede 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 "Post genitore #{m['first_post_id']} non esiste. Salto #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # Applica tag
    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

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

    # Alcuni link sembrano così: <!-- 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)')

    # Molti tag bbcode phpbb hanno un hash attaccato. Esempi:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]clicca qui[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Alcun testo.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Rimuovi i tag video mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB accorcia il testo dei link in questo modo, che interrompe l'elaborazione markdown:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # Soluzione per l'errore: xenforo.rb: 160: in `gsub!': sequenza di byte non valida in UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # Soluzione temporanea:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

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

    # Citazioni nidificate
    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, "")

    # converti i tag di lista in ul e i tag list=1 in ol
    # (in pratica, ci manca solo list=a qui...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # converti i tag *- in tag li in modo che bbcode-to-md possa fare la sua magia sulle liste di 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"]titolo[/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 }

    # Rimuovi il tag colore
    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
        # Rimuovi allegato
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} non trovato nel database sorgente. Rimozione."
        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 "Impossibile trovare upload: #{upload.id}. Salto allegato 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 "Impossibile trovare file #{path}. Salto allegato 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 salvato"
      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 "Errore: Upload non salvato per #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

Note:

  • Aggiunge un passaggio/metodo import_avatars (devono essere jpg)
  • Aggiungiamo i percorsi per avatar e foto profilo
  • Aggiungiamo l’ID del nuovo utente ospite creato come fallback quando un utente non esiste ma un post esiste (utenti ospiti)

Ora copiamo le foto profilo da usare come avatar se esistono - se non esistono verrà usato l’avatar dell’utente se ne ha caricato uno. Puoi saltare questo passaggio se vuoi solo un’importazione diretta da avatar a avatar.

Copiatore foto profilo:

Per prima cosa, installa Down con gem install down

Poi crea un nuovo file con:

require 'down'

(1..NUMERO_UTENTI).each do |u|
  puts "Recupero utente #{u}"
  puts ""
  profile_pic_url = "https://www.nome-forum.com/image.php?u=#{u}&type=profile"
  destination = "/percorso/completo/dove/vuoi/salvare/foto/profilo/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Completato #{u}"
  rescue
    puts "Fallito #{u}"
  end
  puts ""
end

Note:

  • Presuppone che tutte le foto profilo (e gli avatar) siano jpg. Fortunatamente abbiamo permesso solo jpg come avatar e foto profilo, quindi funziona per noi.
  • Assicurati che i percorsi e l’URL siano corretti e che i profili e le foto profilo siano visibili agli ospiti.
  • Sostituisci NUMERO_UTENTI con il numero di utenti che hai (es. 3872).

Poi esegui lo script nel terminale con ruby /percorso/nome-script.rb nel terminale. Questo copierà tutte le tue foto profilo in quella directory, poi vai semplicemente lì, ordina per dimensione file ed elimina tutti i file vuoti (ce ne saranno molti, poiché non tutti caricano una foto profilo).

Esecuzione dell’importazione:

Una volta fatto tutto quanto sopra sei pronto per iniziare :smiley:

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

Ci vogliono circa 90 minuti per importare un forum con 100K post e qualche migliaio di membri e i miei test iniziali sembrano mostrare che funziona bene. Tuttavia..

NOTE:

  • Importerà solo il testo location e about dai profili
  • Non ho controllato i caricamenti di allegati poiché non abbiamo mai permesso caricamenti di allegati sul forum di test che sto usando per questi test. Abbiamo avuto un forum che voglio importare (è molto più grande, ecco perché uso questo forum più piccolo per i test), quindi riferirò su come andrà.
  • L’importazione eseguita sulla macchina di sviluppo è stata ora spostata/“ripristinata” con successo in un’installazione di produzione live ed è andata tutto bene :+1:
  • (Devo ancora testarlo su un forum più grande con allegati - aggiornerò questo post quando sarà fatto)

Pubblico questo ora perché penso che alcune persone stiano cercando di importare i loro forum vB3 su Discourse al momento.

5 Mi Piace