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://google.com:1qh1i7ky]clique aqui[/url:1qh1i7ky]
# [quote="cybereality":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 ![]()
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
locationeaboutdos 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

- (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.