Grande migração de fórum Drupal, erros e limitações do importador

Olá, este tópico fornece algum contexto sobre a migração que estou lentamente planejando e testando. Finalmente, tentei o importador Drupal na sexta-feira passada em um ambiente de teste VPS usando uma combinação de este e este. O importador ainda está em execução enquanto digito isso, então ainda não consegui testar a funcionalidade do site de teste, mas ele está prestes a terminar em breve.

O maior problema que estou enfrentando é um “valor de chave duplicado” em 8 nós aparentemente aleatórios (o equivalente a tópicos no Discourse) de um total de ~80.000 nós. Estes são os números nid específicos, caso haja algum bug de matemática muito estranho do tipo Y2K em jogo:

42081, 53125, 57807, 63932, 66756, 76561, 78250, 82707

Este mesmo erro sempre acontece nos mesmos nids ao reexecutar o importador:

Traceback (most recent call last):
	19: from script/import_scripts/drupal.rb:537:in `<main>'
	18: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	17: from script/import_scripts/drupal.rb:39:in `execute'
	16: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	14: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	13: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	12: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	11: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 3: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 2: from /var/www/discourse/script/import_scripts/base.rb:231:in `block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  duplicate key value violates unique constraint "import_ids_pkey" (PG::UniqueViolation)
DETAIL:  Key (val)=(nid:42081) already exists.
	20: from script/import_scripts/drupal.rb:537:in `<main>'
	19: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	18: from script/import_scripts/drupal.rb:39:in `execute'
	17: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	16: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	14: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	13: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	12: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	11: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 3: from /var/www/discourse/script/import_scripts/base.rb:243:in `block in all_records_exist?'
	 2: from /var/www/discourse/script/import_scripts/base.rb:243:in `ensure in block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  current transaction is aborted, commands ignored until end of transaction block (PG::InFailedSqlTransaction)

A única maneira que consegui fazer com que ele prosseguisse foi alterando as condições SQL:

...
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
AND n.nid != 42081
AND n.nid != 53125
AND n.nid != 57807
AND n.nid != 63932
AND n.nid != 66756
AND n.nid != 76561
AND n.nid != 78250
AND n.nid != 82707
         LIMIT #{BATCH_SIZE}
        OFFSET #{offset};
...

Inspecionei o primeiro nó com falha, bem como os nids anteriores e posteriores a ele no banco de dados Drupal de origem, e não consigo ver nada de errado. O nid é definido como a chave primária e tem AUTO_INCREMENT, e o site Drupal original funciona bem, então não pode haver nenhum problema fundamental com a integridade do banco de dados de origem.


Além do bug acima, estas são as limitações que estou tendo com o script:

  1. Permalinks: Parece que o script importador criará permalinks para os antigos URLs de nós example.com/node/XXXXXXX. Mas também preciso manter links para comentários específicos dentro desses nós, que têm o formato: example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY é o mesmo em ambas as ocorrências). O esquema de URL do Drupal não inclui o ID do nó ao qual o comentário está associado, enquanto o Discourse sim (example.com/t/topic-keywords/XXXXXXX/YY), o que parece ser uma complicação importante.

  2. Limitações de nome de usuário: O Drupal permite espaços em nomes de usuário. Entendo que o Discourse não permite, pelo menos não permite que novos usuários os criem dessa forma. Esta postagem sugere que o script importador “converterá” automaticamente os nomes de usuário problemáticos, mas não vejo nenhum código para isso em /import_scripts/drupal.rb. Atualização: Na verdade, parece que o Discourse lidou com isso automaticamente da maneira correta.

  3. Usuários banidos: Parece que o script importa todos os usuários, incluindo contas banidas. Eu provavelmente poderia adicionar uma condição muito facilmente à seleção SQL WHERE status = 1 para importar apenas contas de usuário ativas, mas não tenho certeza se isso causaria problemas com a serialização dos registros. Acima de tudo, eu preferiria manter esses nomes de contas previamente banidos com seus endereços de e-mail associados permanentemente bloqueados para que os mesmos usuários problemáticos não se registrem novamente no Discourse.

  4. Campos de perfil de usuário: Alguém sabe se há código de exemplo em um dos outros importadores para importar campos de informações pessoais dos perfis de contas de usuário? Tenho apenas um campo de perfil (“Localização”) que preciso importar.

  5. Avatares (não Gravatars): Parece um pouco estranho que haja código no importador Drupal para importar Gravatars, mas não para as imagens de avatar de conta local muito mais usadas.

  6. Mensagens privadas: Quase todos os fóruns Drupal 7 provavelmente usarão o módulo de terceiros privatemsg (não há funcionalidade oficial de PM no Drupal). O importador não suporta a importação de PMs. No meu caso, preciso importar cerca de 1,5 milhão deles.

Obrigado antecipadamente pela sua ajuda e por disponibilizar o script importador Drupal.

Este conjunto de problemas é bastante comum para uma grande importação. Quem quer que tenha sido escrito não se importou (talvez não o suficiente para notar) com os problemas que você descreve.

O que parece ser um bug no Drupal ou no próprio banco de dados (IDs duplicados não deveriam acontecer). Eu provavelmente teria modificado o script para testar e/ou capturar o erro quando houver duplicatas, mas o seu jeito funcionou (a menos que ainda haja mais).

Você pode procurar em outros scripts de importação que criam permalinks de posts. O import_id está no PostCustomField de cada post.

Está em base.rb ou no sugeridor de nomes de usuário. Geralmente funciona e não há muito que você possa fazer para mudá-lo.

Provavelmente você não vai querer fazer isso. O problema é que os posts criados por esses usuários serão de propriedade do system. Você pode procurar em outros scripts exemplos de como desativá-los. O fluxbb tem um script suspend_users, que deve ajudar.

O fluxbb (no qual estou trabalhando agora) faz isso. Você só precisa adicionar algo como isto ao script de importação de usuários:

          location: user['location'],

Gravatars são tratados pelo core do discourse, então o script não faz nada para importá-los; apenas funciona. Você pode usar grep nos outros scripts para “avatar” para encontrar exemplos de como fazer isso.

Procure por exemplos. . . . O ipboard tem import_private_messages.

Obrigado pela resposta. Não acho que seja um problema com o banco de dados do Drupal, porque inspecionei o banco de dados de origem e não consigo encontrar chaves nid duplicadas.

Ahhh, então ele tem essa funcionalidade fora de drupal.rb. Agora que estou explorando o site de importação de teste, na verdade parece que ele lidou muito bem com as conversões de nome de usuário. Obrigado!

Qual seria a maneira mais fácil de habilitar a importação de nomes de usuário Unicode (sem convertê-los, ou seja, manter o nome de usuário Narizón em vez de convertê-lo para Narizon)?

Fiz meu primeiro teste do importador do Drupal em uma instância sem GUI web configurada, então não defini a opção do Discourse para permitir nomes de usuário Unicode. Se isso tivesse sido definido, o importador teria respeitado? Qual é a maneira recomendada de habilitar isso para quando eu executar minha migração de produção?

E, enquanto isso, para minha instância de teste atual, existe algum comando rake para aplicar o nome completo ao nome de usuário? (Eu já ativei prioritize username in ux, mas como meus usuários de teste estão acostumados com o Drupal, que suporta apenas nomes de usuário para login [não endereço de e-mail], acho que seria melhor manter seus nomes de usuário de produção, que pelo menos foram mantidos no campo fullname.)

Provavelmente?

Você pode definir a configuração do site no início do script.

Eu acho que mudar nomes de usuário é uma má ideia, mas se você não gosta deles, você pode mudar o que é passado para o gerador de nomes de usuário.

Obrigado, você quer dizer mudá-los após a importação ser concluída?

Eu acho que quero dizer alterá-los, a menos que no sistema antigo os nomes de usuário fossem invisíveis e eles vissem apenas nomes reais.

Se for este o caso, então eu mudaria o script para fazer o nome de usuário ser o nome real deles. O problema com isso é que, se eles não souberem o endereço de e-mail deles, não conseguirão encontrar a conta deles.

Entendi. No fórum do Drupal, existem apenas nomes de usuário do sistema e nenhum nome real separado. E adicionalmente, o Drupal não permite o login com o endereço de e-mail, apenas com o nome de usuário. É por isso que é muito importante no meu caso manter os nomes de usuário o máximo possível. (Ainda haverá alguns nomes de usuário convertidos, como aqueles com espaços.) Portanto, preciso investigar como definir as configurações do Discourse no início do script de importação.

Mas o Discourse permite, então se eles souberem o endereço de e-mail, eles podem usá-lo para redefinir a senha, que é provavelmente o que você deveria dizer a todos para fazerem, já que você não pode adivinhar quem não consegue adivinhar o nome de usuário deles, eu acho.

Eu acho que o que eu faria seria definir SiteSetting.unicode_username=true no script de importação e executá-lo novamente para ver se funciona. Você pode tentar testá-lo no console do Rails para ver. Isso pode lhe dizer:

  User.create(username: 'Narizón', email: 'user@x.com',password: 'xxx', active: true)

Bem, eu acho que isso ainda não chamaria a função de criação de nome de usuário, então você precisará chamá-la

  UserNameSuggester.suggest("Narizón")

Não. Isso ainda não lhe dará um nome de usuário unicode. Você precisará encontrar o UserNameSuggester e ajustá-lo, eu acho.

Mas se você realmente quiser mudar os nomes de usuário, mudá-los agora em vez de corrigir o script pode ser o que você quer fazer. Você precisa ter certeza de que a maneira como você faz isso atualiza o nome de usuário em todas as postagens. Se você estiver usando uma tarefa rake, ela definitivamente fará isso.

Excelente, muito obrigado Jay! Vou tentar isso na próxima vez que executar o importador.

Acho que você não deveria se incomodar:

Isso está em lib/user_name_suggester.rb, mas talvez você queira User.normalize_username

Com certeza, você estava certo. Nem é um bug em si, acabou sendo uma forma estranha que o Drupal lida com tópicos movidos, deixando um rastro na antiga categoria do tópico. Ele simplesmente cria uma linha duplicada em uma das muitas tabelas que são puxadas para o que eventualmente se torna um tópico completo do Drupal. Então, parece que preciso descobrir como aplicar DISTINCT a apenas uma das tabelas que são selecionadas…

Sim. É incrível como cada importação é única, e de alguma forma o seu é o primeiro fórum a ter esse problema (claro, muitas pessoas podem ter resolvido o problema e não conseguiram enviar um PR com a atualização). Ou talvez eles ignoraram os erros?

Aha. Suspeito que não seja uma função muito usada, quando um tópico é movido para uma nova categoria, há uma caixa de seleção opcional para deixar um link “Movido para…” na categoria antiga.

A duplicata ofensiva está na coluna nid de forum_index. Então, parece que posso corrigi-la com um GROUP BY nid, certo?

        SELECT fi.nid nid,
               fi.title title,
               fi.tid tid,
               n.uid uid,
               fi.created created,
               fi.sticky sticky,
               f.body_value body,
	       nc.totalcount views
          FROM forum_index fi
	 LEFT JOIN node n ON fi.nid = n.nid
	 LEFT JOIN field_data_body f ON f.entity_id = n.nid
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
         GROUP BY nid

Parece promissor, porque quando executo a consulta com o GROUP BY nid, há 8 linhas a menos.

Isso pode funcionar. Eu pensaria que haveria algum valor nessa tabela que indicasse que ela havia sido movida e você poderia selecionar apenas aquelas sem esse valor.

Essa seria definitivamente a maneira mais lógica de projetá-la. Acho que é uma coisa do Drupal…

A única coisa que ele faz é alterar o tid (ID da categoria). Isso segue o estilo que aprendi durante essa provação com o banco de dados Drupal. Não sei nada sobre design de banco de dados, mas tenho a impressão de que você pode armazenar dados explicitamente, ou então pode deixar algumas coisas implícitas e depois descobri-las por meio de lógica programática; o Drupal parece cair firmemente no último grupo.

Bem, parece que estou quase lá. Muito obrigado ao Jay pela orientação.

Obrigado, isso foi essencial. Na verdade, foi tão simples quanto copiar a parte do permalink do próprio script de importação do Drupal e adaptá-lo para funcionar em posts em vez de tópicos:

    ## Adicionei permalinks para cada link de comentário (resposta) do Drupal: /comment/DIGITOS#comment-DIGITOS
    Post.find_each do |post|
      begin
        pcf = post.custom_fields
        if pcf && pcf['import_id']
          cid = pcf['import_id'][/cid:(\d+)/, 1]
          slug = "/comment/#{cid}" # A parte #comment-DIGITOS quebra o permalink e não é necessária
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Falha na criação do permalink para cid #{post.id}"
      end
    end

Fiquei preso por um tempo com minha tentativa original, que incluía a parte relativa da página #comment-DIGITOS do link original do Drupal, o que quebrava completamente o permalink no Discourse. Então percebi que, é claro, a parte # de um link não é realmente enviada ao servidor web e só era necessária no Drupal para fazer a página rolar até a parte onde o comentário específico estava localizado. Então funciona perfeitamente sem isso no Discourse, mesmo vindo de uma página web externa com um antigo link /comment/YYYYYY#comment-YYYYY. No Discourse, ele simplesmente aparece assim: /comment/YYYYYY/t/titulo-do-topico-palavras/123456/X, e a barra de URL mostra: /t/titulo-do-topico-palavras/123456/X#comment-YYYYYY. Parece que ele não se importa com a parte falsa #comment-YYYYYY.

Para alguns fóruns, suspeito que a função postprocess_posts do importador padrão do Drupal possa ser suficiente. Vale ressaltar que ela precisa ser ajustada para cada fórum; há uma substituição de expressão regular bastante descuidada e hardcoded de site.comcommunity.site.com. Mas após esse ajuste, ela faz um bom trabalho reescrevendo links internos do fórum, convertendo nós → tópicos, bem como comentários → respostas. No entanto, tenho um número considerável de sites externos linkando para comentários individuais (respostas) no meu fórum, e vale a pena preservar esses links. Além disso, o Google indexa a maioria dos 1,7 milhão de URLs /comment-YYYYYY, e provavelmente prejudicaria meu ranking se todas elas desaparecessem. Espero que ter ~2 milhões de permalinks não cause problemas ao Discourse?


Muito obrigado, eu adaptei essa função quase sem modificações, apenas precisei ajustar alguns nomes de colunas. Funciona perfeitamente.

  def suspend_users
    puts '', "atualizando usuários banidos"

    banned = 0
    failed = 0
    total = mysql_query("SELECT COUNT(*) AS count FROM users WHERE status = 0").first['count']

    system_user = Discourse.system_user

    mysql_query("SELECT name username, mail email FROM users WHERE status = 0").each do |b|
      user = User.find_by_email(b['email'])
      if user
        user.suspended_at = Time.now
        user.suspended_till = 200.years.from_now

        if user.save
          StaffActionLogger.new(system_user).log_user_suspend(user, "banido durante a importação inicial")
          banned += 1
        else
          puts "Falha ao suspender usuário #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "Não encontrado: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

Também funcionou! Precisei lidar com o esquema de banco de dados difuso do Drupal e usar LEFT JOIN profile_value location ON users.uid = location.uid para correlacionar outra tabela que contém os dados do perfil, mas é muito legal como é fácil adicionar isso no lado do Discourse. Vale notar que esse processo roda cerca de 50% mais devagar que o padrão; suspeito que seja devido ao LEFT JOIN. Mas posso viver com isso, já que tenho apenas cerca de 80 mil usuários.


Isso foi bastante difícil, mais uma vez devido ao esquema de banco de dados desconexo do Drupal. Acabei usando o jforum.rb como base, com uma pequena ajuda do importador do Vanilla também. O script original era bastante paranoico, verificando em cada passagem de variável para garantir que o nome do arquivo do avatar não fosse nulo, então removi a maioria dessas verificações para deixar o código menos bagunçado. O pior que pode acontecer é o script travar, mas com a consulta SQL que usei, não acho que isso nem possa dar errado.

  def import_users
    puts "", "importando usuários"

    user_count = mysql_query("SELECT count(uid) count FROM users").first["count"]

    last_user_id = -1
    
    batches(BATCH_SIZE) do |offset|
      users = mysql_query(<<-SQL
          SELECT users.uid,
                 name username,
                 mail email,
                 created,
                 picture,
                 location.value location
            FROM users
             LEFT JOIN profile_value location ON users.uid = location.uid
           WHERE users.uid > #{last_user_id}
        ORDER BY uid
           LIMIT #{BATCH_SIZE}
      SQL
      ).to_a

      break if users.empty?

      last_user_id = users[-1]["uid"]

      users.reject! { |u| @lookup.user_already_imported?(u["uid"]) }

      create_users(users, total: user_count, offset: offset) do |row|
        if row['picture'] > 0
        	q = mysql_query("SELECT filename FROM file_managed WHERE fid = #{row['picture']};").first
        	avatar = q["filename"]
        end
        email = row["email"].presence || fake_email
        email = fake_email if !EmailAddressValidator.valid_value?(email)

        username = @htmlentities.decode(row["username"]).strip

        {
          id: row["uid"],
          name: username,
          email: email,
          location: row["location"],
          created_at: Time.zone.at(row["created"]),
	  	post_create_action: proc do |user|
		    import_avatar(user, avatar)
		end
        }
      end 
    end
  end
  def import_avatar(user, avatar_source)
    return if avatar_source.blank?

    path = File.join(ATTACHMENT_DIR, avatar_source)

      @uploader.create_avatar(user, path)
  end

Após sua ajuda paga com a consulta SQL, acabei tentando adaptá-la aos scripts do Discuz, IPboard e Xenforo. Continuei batendo em becos sem saída com cada um; cheguei mais perto com o modelo do Discuz, que parece ter um esquema de banco de dados muito similar, mas não consegui superar um bug com a variável de instância @first_post_id_by_topic_id. Após toneladas de tentativa e erro, finalmente percebi que ela estava sendo inicializada incorretamente no início do script do Discuz (tentei colocá-la no mesmo local no script do Drupal), e isso finalmente corrigiu o problema:

  def initialize
    super
    
    @first_post_id_by_topic_id = {}

    @htmlentities = HTMLEntities.new

    @client = Mysql2::Client.new(
      host: "172.17.0.3",
      username: "user",
      password: "pass",
      database: DRUPAL_DB
    )
  end

def import_private_messages
	puts '', 'criando mensagens privadas'

	pm_indexes = 'pm_index'
	pm_messages = 'pm_message'
	total_count = mysql_query("SELECT count(*) count FROM #{pm_indexes}").first['count']

	batches(BATCH_SIZE) do |offset|
		results = mysql_query("
SELECT pi.mid id, thread_id, pi.recipient to_user_id, pi.deleted deleted, pm.author user_id, pm.subject subject, pm.body message, pm.format format, pm.timestamp created_at FROM pm_index pi LEFT JOIN pm_message pm ON pi.mid=pm.mid WHERE deleted = 0
             LIMIT #{BATCH_SIZE}
            OFFSET #{offset};")

		break if results.size < 1

		# next if all_records_exist? :posts, results.map {|m| "pm:#{m['id']}"}

		create_posts(results, total: total_count, offset: offset) do |m|
			skip = false
			mapped = {}
			mapped[:id] = "pm:#{m['id']}"
			mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || -1
			mapped[:raw] = preprocess_raw(m['message'],m['format'])
			mapped[:created_at] = Time.zone.at(m['created_at'])
			thread_id = "pm_#{m['thread_id']}"
			if is_first_pm(m['id'], m['thread_id'])
				# encontrar o título na tabela de lista
				#          pm_thread = mysql_query("
				#                SELECT thread_id, subject
				#                  FROM #{table_name 'ucenter_pm_lists'}
				#                 WHERE plid = #{m['thread_id']};").first
				mapped[:title] = m['subject']
				mapped[:archetype] = Archetype.private_message

          # Encontrar os usuários que fazem parte desta mensagem privada.
          import_user_ids = mysql_query("
                SELECT thread_id plid, recipient user_id
                  FROM pm_index
                 WHERE thread_id = #{m['thread_id']};
              ").map { |r| r['user_id'] }.uniq
          mapped[:target_usernames] = import_user_ids.map! do |import_user_id|
            import_user_id.to_s == m['user_id'].to_s ? nil : User.find_by(id: user_id_from_imported_user_id(import_user_id)).try(:username)
          end.compact
          if mapped[:target_usernames].empty? # pm consigo mesmo?
            skip = true
            puts "Ignorando pm:#{m['id']} devido à falta de destinatário"
          else
            @first_post_id_by_topic_id[thread_id] = mapped[:id]
          end
        else
          parent = topic_lookup_from_imported_post_id(@first_post_id_by_topic_id[thread_id])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Post pai do thread de pm:#{thread_id} não existe. Ignorando #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# procurar pelo primeiro id de pm da série de pm
def is_first_pm(pm_id, thread_id)
	result = mysql_query("
          SELECT mid id
            FROM pm_index
           WHERE thread_id = #{thread_id}
        ORDER BY id")
	result.first['id'].to_s == pm_id.to_s
end

Ah, e para a maioria dessas consultas, também é necessário executar o seguinte no contêiner MySQL para desativar uma verificação de sanidade SQL em modo estrito:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Outra coisa que percebi que faltava eram alguns milhares de nós do Drupal do tipo poll (enquete). Primeiro, tentei apenas incluir WHERE type = 'forum' OR type = 'poll' na função import_topics, mas há coisas realmente estranhas acontecendo no banco de dados original do Drupal que fazem com que muitos deles sejam ignorados. Então, acabei copiando o import_topics para uma nova função import_polls:

    def import_poll_topics
    puts '', "importando tópicos de enquete"

    polls = mysql_query(<<-SQL
      SELECT n.nid nid, n.title title, n.uid uid, n.created created, n.sticky sticky, taxonomy_index.tid tid, node_counter.totalcount views
        FROM node n
        LEFT JOIN taxonomy_index ON n.nid = taxonomy_index.nid
        LEFT JOIN node_counter ON n.nid = node_counter.nid
       WHERE n.type = 'poll'
         AND n.status = 1
    SQL
    ).to_a

    create_posts(polls) do |topic|
      {
        id: "nid:#{topic['nid']}",
        user_id: user_id_from_imported_user_id(topic['uid']) || -1,
        category: category_id_from_imported_category_id(topic['tid']),
        raw: "### Você pode ver os resultados arquivados da enquete no Wayback Machine:\n**https://web.archive.org/web/1234567890/http://myforum.com/node/#{topic['nid']}**",
        created_at: Time.zone.at(topic['created']),
        pinned_at: topic['sticky'].to_i == 1 ? Time.zone.at(topic['created']) : nil,
        title: topic['title'].try(:strip),
        views: topic['views'],
        custom_fields: { import_id: "nid:#{topic['nid']}" }
      }
    end
  end

Não me importo muito em importar os resultados reais da enquete, e isso exigiria reescrever todo o algoritmo que o Drupal usa para somar todos os votos e eliminar duplicatas. Principalmente, quero apenas importar os comentários de acompanhamento no thread da enquete. Mas, caso alguém queira ver os resultados originais da enquete, fiz com que ele escrevesse um link direto para o nó original do fórum no Wayback Machine.


Então, o código não é de forma alguma elegante e provavelmente não é muito eficiente, mas para um trabalho único, deve fazer o serviço.

Desculpe pelas paredes de código; me avise se isso irritar alguém e posso movê-los para uma URL do pastebin.

E é assim que a maioria deles vai. Este tópico é um ótimo exemplo para outra pessoa seguir (dado que eles comecem com um conjunto de habilidades semelhante ao seu).

Você tem uma estimativa de quanto tempo gastou fazendo suas personalizações?

Parabéns!

Obrigado, Jay! Agradeço o incentivo.

Ugh, prefiro não pensar nisso. :stuck_out_tongue_winking_eye: Provavelmente foram mais de 15 ou 20 horas depois que você me colocou no caminho certo com a consulta SQL.

Gostaria de conversar com você sobre isso se tiver alguma ideia:

Levou cerca de 70 horas para fazer um teste completo com dados de produção em um VPS muito poderoso. Gostaria de fazer com que meus usuários interajam novamente o mais rápido possível, mesmo que a importação de posts e MPs ainda esteja incompleta. Ou outra ideia alternativa que pensei seria desabilitar a função preprocess_posts, que também modifiquei bastante com substituições adicionais de regex gsub e também para passar todos os posts e MPs pelo Pandoc com um ou dois comandos diferentes, dependendo se o post original era marcação Textile ou HTML puro. Se eu desabilitar toda a rotina preprocess_posts, provavelmente cortaria o tempo de importação pela metade, e então eu poderia adicionar todo esse material de formatação à seção postprocess_posts depois que todos os dados brutos fossem importados. Mas a desvantagem é que, depois, eu não conseguiria acessar facilmente a coluna original do banco de dados que mostra o formato de origem (Textile ou HTML) para cada post, o que é uma condição para minha manipulação do Pandoc. Ou eu poderia adicionar um campo personalizado a cada post, rotulando-o como textile ou html e recuperá-lo mais tarde durante o pós-processamento? Sei lá, apenas pensando alto aqui.

Quando você executar o script de importação novamente apenas com os novos dados, ele será executado muito mais rápido, pois não importará os dados novamente. Assim, levará apenas algumas horas. E cada execução subsequente será mais rápida, pois haverá menos dados para importar.

Você pode então acelerar isso modificando as consultas para retornar apenas dados mais recentes que um determinado tempo. A maioria dos scripts que toquei tem uma configuração de import_after para esse propósito (mas também para permitir um desenvolvimento mais rápido importando um pequeno subconjunto dos dados).