Migrieren Sie ein vBulletin 3-Forum zu Discourse über XenForo

Ich schreibe das auf, solange es mir noch frisch im Kopf ist – es ist derzeit ein Arbeitsschritt, also stellt sicher, dass ihr es testet und prüft, ob es euren Anforderungen entspricht.

Soweit ich weiß, gibt es keinen Importer von vB3 zu Discourse, und ich habe keine vB4/5-Lizenzen, für die ich denke, dass die Discourse-Importer gedacht sind – aber ich habe eine XenForo 1.4-Lizenz und dafür gibt es einen Discourse-Importer! Für diejenigen, die keine XF-Lizenz haben: Man kann sie oft auf dem Gebrauchtmarkt kaufen, oder man fragt jemanden (oder bezahlt jemanden), der den Import für euch durchführt und euch die XF-Datenbank gibt.

Ich habe bereits zuvor einen Import von vB3.6 nach XF durchgeführt, und ich weiß, dass das funktioniert (das Einzige, was nicht importiert wird, sind Profilbilder, da XF nur Avatare unterstützt – aber ich habe eine Lösung dafür).

Ok…

Importiert zuerst euer vB-Forum wie gewohnt nach XF.

Ich empfehle, euer vB-Forum online und im Internet zugänglich zu lassen (führt also den Import nach XF in ein Unterverzeichnis durch). Das dient als Sicherheitsnetz, falls ihr später entscheiden solltet, das vB-Forum zu behalten, und auch, weil wir Profilbilder von der live-Seite kopieren werden (ihr könnt jedoch vorher das untenstehende Skript zum Kopieren von Profilbildern verwenden, falls ihr das unbedingt braucht).

Sobald ihr überprüft habt, dass das Forum erfolgreich nach XF konvertiert wurde, erstellt ein Backup dieser neuen Datenbank und kopiert es auf eure Entwicklungsmaschine.

Meine Entwicklungsmaschine ist ein Mac, daher sind diese Anweisungen für macOS gedacht.

brew install mysql 
// Stellt auch sicher, dass er gestartet ist

mysql -u root

create database xenforo_db;
exit;

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

Richtet eure Discourse-Entwicklungsumgebung wie gewohnt ein (siehe diesen Leitfaden für macOS) und führt dann Folgendes aus:

Öffnet database.yml und ändert den Datenbanknamen in etwas wie discourse_development_sitename_01 – verwendet Zahlen, damit ihr den Import mehrfach wiederholen könnt, indem ihr einfach die Zahl ändert.

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

Für das erste Admin-Konto versucht, dieselbe E-Mail-Adresse wie euer bestehendes Admin-Konto auf eurer vB/XF-Installation zu verwenden. Wählt „Y“, wenn gefragt wird, ob ihr ihm Admin-Rechte geben wollt.

Für den zweiten Durchgang beim Erstellen eines Kontos verwendet eine E-Mail wie guest@something.com und wählt „n“, wenn gefragt wird, ob ihr es als Admin-Konto erstellen wollt. Wir benötigen dieses Konto für Beiträge, die mit Gästen/gelöschten Benutzern verknüpft sind. Ihr könnt in rails c gehen und dann User.last eingeben, um seine ID zu überprüfen, aber sie wird wahrscheinlich 2 sein. Wir werden dies später in das Importskript einfügen.

Ich habe einige Änderungen am Importskript vorgenommen, hier ist meine Version des Skripts (ersetzt den Inhalt von script/import_scripts/xenforo.rb damit):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # möglicherweise nicht benötigt – kann mich nicht erinnern, warum ich es hinzugefügt habe
require "htmlentities" # möglicherweise nicht benötigt – kann mich nicht erinnern, warum ich es hinzugefügt habe

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

# Aufruf wie folgt:
#   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 '', "Erstellen von Benutzern"

    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 "Importieren von Benutzerprofilen..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importieren von Profilen: Abrufen der Informationen"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Aktualisieren des Profils für #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "Importieren von Kategorien..."

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

    # tiefere Kategorien müssen als Tags behandelt werden
    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'])

      # Finde eine Unterkategorie für Themen in dieser Kategorie
      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 '', "Konnte keine Kategorie für #{c['id']} '#{c['title']}' finden!"
      end
    end
  end

  # Diese Methode ist eine Alternative zu import_categories.
  # Sie verwendet Präfixe statt Knoten.
  def import_categories_from_thread_prefixes
    puts "", "Importieren von Kategorien..."

    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 "", "Erstellen von Themen und Beiträgen"

    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}" # benötigt 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 "Elternbeitrag #{m['first_post_id']} existiert nicht. Überspringe #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # Tags anwenden
    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

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

    # Einige Links sehen so aus: <!-- 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)')

    # Viele phpBB-BBCode-Tags haben einen Hash angehängt. Beispiele:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]hier klicken[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Einiger Text.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Entferne meine mybb-Video-Tags.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB kürzt Link-Texte so, was unsere Markdown-Verarbeitung unterbricht:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # Fix für den Fehler: 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

    # Umgehung vorübergehend:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

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

    # Verschachtelte Zitate
    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, "")

    # Konvertiere List-Tags in ul und list=1-Tags in ol
    # (grundsätzlich fehlt uns hier nur list=a...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # Konvertiere *-Tags in li-Tags, damit bbcode-to-md seine Magie bei phpBB-Listen vollbringen kann:
    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 }

    # Entferne das Farb-Tag
    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
        # Entferne Anhang
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} nicht in der Quelldatenbank gefunden. Entferne."
        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 "Konnte Upload nicht finden: #{upload.id}. Überspringe Anhang 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 "Konnte Datei #{path} nicht finden. Überspringe Anhang 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 gespeichert"
      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 "Fehler: Upload wurde für #{u.username} #{filename} nicht gespeichert!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

Hinweise:

  • Es wird ein Schritt/ eine Methode import_avatars hinzugefügt (diese müssen JPGs sein)
  • Wir fügen Pfade zu Avataren und Profilbildern hinzu
  • Wir fügen die ID des neu erstellten Gast-Benutzers als Fallback hinzu, falls ein Benutzer nicht existiert, aber ein Beitrag existiert (Gast-Benutzer)

Kopieren wir nun Profilbilder als Avatare, falls vorhanden – falls nicht, wird das Avatar des Benutzers verwendet, falls er eines hochgeladen hat. Dies kannst du überspringen, wenn du nur eine direkte Avatar-zu-Avatar-Importierung möchtest.

Profilbild-Kopierer:

Installiere zuerst Down mit gem install down

Erstelle dann eine neue Datei mit:

require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Abrufen von Benutzer #{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 "Abgeschlossen #{u}"
  rescue
    puts "Fehlgeschlagen #{u}"
  end
  puts ""
end

Hinweise:

  • Geht davon aus, dass alle Profilbilder (und Avatare) JPGs sind. Glücklicherweise haben wir nur JPGs als Avatare und Profilbilder erlaubt, also funktioniert das bei uns.
  • Stelle sicher, dass deine Pfade und URL korrekt sind und dass Profile und Profilbilder für Gäste sichtbar sind.
  • Ersetze NUMBER_OF_USERS durch die Anzahl deiner Benutzer (z. B. 3872).

Führe dann das Skript im Terminal aus, indem du ruby /path/to/name-of-script.rb eingibst. Dies kopiert alle deine Profilbilder in dieses Verzeichnis. Gehe dann dorthin, sortiere nach Dateigröße und lösche alle leeren Dateien (es wird viele geben – da nicht jeder ein Profilbild hochlädt).

Durchführung des Imports:

Sobald alles oben Genannte erledigt ist, bist du bereit zu starten :smiley:

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

Es dauert etwa 90 Minuten, ein Forum mit 100.000 Beiträgen und einigen tausend Mitgliedern zu importieren, und meine ersten Tests scheinen zu zeigen, dass es gut funktioniert. Allerdings..

HINWEISE:

  • Es werden nur location und about-Texte aus Profilen importiert
  • Ich habe das Hochladen von Anhängen nicht überprüft, da wir auf dem Testforum, das ich für diese Tests verwende, niemals das Hochladen von Anhängen erlaubt haben. Auf einem der Foren, die ich importieren möchte (es ist viel größer, daher verwende ich dieses kleinere Forum für Tests), haben wir das jedoch erlaubt und werde berichten, wie es läuft.
  • Der auf der Entwicklungsmaschine durchgeführte Import wurde erfolgreich in eine Live-Produktionsinstallation verschoben/„wiederhergestellt“ und alles verlief gut :+1:
  • (Muss dies noch auf einem größeren Forum mit Anhängen testen – ich werde diesen Beitrag aktualisieren, wenn das erledigt ist)

Ich poste dies jetzt, da ich denke, dass einige Leute derzeit versuchen, ihre vB3-Foren nach Discourse zu importieren.

5 „Gefällt mir“