Je rédige ceci pendant que c’est encore frais dans mon esprit — c’est actuellement un travail en cours, alors assurez-vous de le tester et de vérifier qu’il répond à vos besoins.
À ma connaissance, il n’existe pas d’importateur de vB3 vers Discourse, et je ne possède aucune licence vB4/5, qui sont celles pour lesquelles les importateurs de Discourse sont conçus. Cependant, je possède une licence XenForo 1.4 et il existe un importateur Discourse pour cela ! Pour ceux qui n’ont pas de licence XF, vous pouvez souvent en acheter d’occasion, ou demander/payer quelqu’un pour effectuer l’importation et vous fournir la base de données XF.
J’ai déjà effectué une importation de vB3.6 vers XF, donc je sais que cela fonctionne bien (la seule chose qui n’est pas importée sont les photos de profil, car XF ne dispose que d’avatars — mais j’ai une solution pour cela).
Ok…
Importez d’abord votre forum vB vers XF comme vous le faites habituellement.
Je recommande de garder votre forum vB en ligne et accessible sur Internet (effectuez donc l’importation vers XF dans un sous-répertoire). C’est simplement une mesure de sécurité au cas où vous décideriez plus tard de conserver le forum vB, et aussi parce que nous allons copier les photos de profil depuis le site en direct (bien que vous puissiez utiliser le script de copie de profil ci-dessous au préalable si vous en avez vraiment besoin).
Une fois que vous avez vérifié que le forum a été converti avec succès vers XF, faites une sauvegarde de cette nouvelle base de données et copiez-la sur votre machine de développement.
Ma machine de développement est un Mac, donc ces instructions sont pour macOS.
brew install mysql
// Assurez-vous également qu'il est démarré
mysql -u root
create database xenforo_db;
exit;
mysql -u root -p xenforo_db < /path/to/your/backup/and/downloaded/xenforo_db.sql
Configurez votre environnement de développement Discourse comme d’habitude (voir ce guide pour macOS), puis :
Ouvrez database.yml et changez le nom de la base de données en quelque chose comme discourse_development_nomdu_site_01 — utilisez des chiffres afin de pouvoir répéter l’importation plusieurs fois simplement en changeant le chiffre.
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
Pour le premier compte administrateur, essayez d’utiliser la même adresse e-mail que votre compte administrateur existant sur votre installation vB/XF. Sélectionnez « O » lorsqu’on vous demande si vous souhaitez lui accorder des permissions d’administrateur.
Pour la deuxième création de compte, utilisez une adresse e-mail du type invité@quelquechose.com et sélectionnez « N » lorsqu’on vous demande de le créer en tant que compte administrateur. Nous avons besoin de ce compte pour les publications associées aux invités/utilisateurs supprimés. Vous pouvez entrer dans rails c puis taper User.last pour vérifier son ID, mais il sera probablement 2. Nous l’ajouterons au script d’importation.
J’ai apporté quelques modifications au script d’importation, voici ma version du script (remplacez le contenu de script/import_scripts/xenforo.rb par celui-ci) :
# frozen_string_literal: true
require "mysql2"
require_relative "base"
require "set" # peut-être pas nécessaire — je ne me souviens plus pourquoi je l'ai ajouté
require "htmlentities" # peut-être pas nécessaire — je ne me souviens plus pourquoi je l'ai ajouté
require File.expand_path(File.dirname(__FILE__) + "/base.rb")
# Appelez-le comme ceci :
# 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 '', "création des utilisateurs"
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 "Importation des profils utilisateurs..."
user_profiles = mysql_query("
SELECT user_id, location, about
FROM #{TABLE_PREFIX}user_profile
ORDER BY user_id;
")
puts "Importation des profils : récupération des informations"
user_profiles.each do |row|
usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
if user = User.find(usf.user_id)
puts "Mise à jour du profil pour #{user.username}"
profile = user.user_profile
profile.location = row["location"]
profile.bio_raw = row["about"]
profile.save
end
end
end
def import_categories
puts "", "importation des catégories..."
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'] })
# les catégories plus profondes doivent devenir des 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'])
# Trouver une sous-catégorie pour les sujets dans cette catégorie
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 '', "Impossible de trouver une catégorie pour #{c['id']} '#{c['title']}' !"
end
end
end
# Cette méthode est une alternative à import_categories.
# Elle utilise les préfixes au lieu des nœuds.
def import_categories_from_thread_prefixes
puts "", "importation des catégories..."
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: "Catégorie-#{category["id"]}"
}
end
@prefix_as_category = true
end
def import_posts
puts "", "création des sujets et des messages"
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}" # nécessite 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 "Le message parent #{m['first_post_id']} n'existe pas. Ignorance de #{m["id"]}: #{m["title"][0..40]}"
skip = true
end
end
skip ? nil : mapped
end
end
# Appliquer les 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
# :) est encodé comme <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')
# Certains liens ressemblent à ceci : <!-- 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)')
# De nombreux balises bbcode phpbb ont un hash attaché. Exemples :
# [url=https://google.com:1qh1i7ky]cliquez ici[/url:1qh1i7ky]
# [quote="cybereality":b0wtlzex]Un texte.[/quote:b0wtlzex]
s.gsub!(/:(?:\w{8})\]/, ']')
# Supprimer les balises vidéo mybb.
s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')
s = CGI.unescapeHTML(s)
# phpBB raccourcit le texte des liens comme ceci, ce qui casse notre traitement markdown :
# [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
#
# Correctif pour l'erreur : 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
# Contournement pour le moment :
s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')
# [QUOTE]...[/QUOTE]
s.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" }
# Citations imbriquées
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 les balises de liste en ul et les balises list=1 en ol
# (en gros, il nous manque seulement list=a ici...)
s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
# convertir les balises *-tags en balises li afin que bbcode-to-md puisse faire sa magie sur les listes 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"]titre[/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 }
# Supprimer la balise de couleur
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
# Supprimer la pièce jointe
s.gsub!(get_xf_regexp(xf_type, id), '')
STDERR.puts "#{xf_type.capitalize} id #{id} introuvable dans la base de données source. Suppression."
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 "Impossible de trouver le téléchargement : #{upload.id}. Ignorance de la pièce jointe 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 "Impossible de trouver le fichier #{path}. Ignorance de la pièce jointe 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 "téléchargement persisté"
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 "Erreur : Le téléchargement n'a pas persisté pour #{u.username} #{filename} !"
end
end
end
ImportScripts::XenForo.new.perform
Notes :
- Il ajoute une étape/méthode
import_avatars(ceux-ci doivent être des jpg) - Nous ajoutons les chemins vers les avatars et les photos de profil
- Nous ajoutons l’ID du nouvel utilisateur invité créé comme solution de repli lorsqu’un utilisateur n’existe pas mais qu’un message existe (utilisateurs invités)
Maintenant, copions les photos de profil pour les utiliser comme avatars si elles existent — si elles n’existent pas, l’avatar de l’utilisateur sera utilisé s’il en a téléchargé un. Vous pouvez sauter cette étape si vous voulez simplement une importation directe d’avatars vers avatars.
Copieur de photo de profil :
D’abord, installez Down avec gem install down
Ensuite, créez un nouveau fichier avec :
require 'down'
(1..NOMBRE_D_UTILISATEURS).each do |u|
puts "Récupération de l'utilisateur #{u}"
puts ""
profile_pic_url = "https://www.nom-du-forum.com/image.php?u=#{u}&type=profile"
destination = "/chemin/complet/où/vous/voulez/sauvegarder/les/photos/de/profil/#{u}.jpg"
begin
Down.download(profile_pic_url, destination: destination)
puts "Terminé #{u}"
rescue
puts "Échec #{u}"
end
puts ""
end
Notes :
- Suppose que toutes les photos de profil (et les avatars) sont des jpg. Heureusement, nous n’avons autorisé que des jpg comme avatars et photos de profil, donc cela fonctionne pour nous.
- Assurez-vous que vos chemins et URL sont corrects et que les profils et les photos de profil sont visibles par les invités.
- Remplacez NOMBRE_D_UTILISATEURS par le nombre d’utilisateurs que vous avez (par exemple 3872).
Ensuite, exécutez le script dans le terminal en tapant ruby /chemin/vers/nom-du-script.rb dans le terminal. Cela copiera toutes vos photos de profil dans ce répertoire, puis il vous suffira d’y aller, de trier par taille de fichier et de supprimer tous les fichiers vides (il y en aura beaucoup — car tout le monde ne télécharge pas de photo de profil).
Réalisation de l’importation :
Une fois tout cela fait, vous êtes prêt à commencer ![]()
RAILS_ENV=development bundle exec ruby script/import_scripts/xenforo.rb
Cela prend environ 90 minutes pour importer un forum avec 100 000 messages et quelques milliers de membres, et mes tests initiaux semblent montrer que cela fonctionne bien. Cependant..
NOTES :
- Il n’importe que le texte
locationetaboutdes profils - Je n’ai pas vérifié les téléchargements de pièces jointes car nous n’avons jamais autorisé les téléchargements de pièces jointes sur le forum de test que j’utilise pour ces tests. Nous avons été sur l’un des forums que je souhaite importer (c’est beaucoup plus grand, d’où l’utilisation de ce plus petit forum pour les tests), donc je ferai un retour sur la suite.
- L’importation effectuée sur la machine de développement a maintenant été déplacée avec succès/rétablie dans une installation de production en direct et tout s’est bien passé

- (Il faut encore tester cela sur un plus grand forum avec des pièces jointes — je mettrai à jour ce message lorsque ce sera fait)
Je publie cela maintenant car je pense que plusieurs personnes essaient actuellement d’importer leurs forums vB3 vers Discourse.