Gran migración de foro Drupal, errores del importador y limitaciones

Hola, este tema proporciona información general sobre la migración que estoy planeando y probando lentamente. Finalmente, probé el importador de Drupal el viernes pasado en un VPS de prueba utilizando una combinación de esto y esto. El importador todavía se está ejecutando mientras escribo esto, por lo que aún no he podido probar la funcionalidad del sitio de prueba, pero está a punto de finalizar pronto.

El mayor problema que enfrento es un “valor de clave duplicada” en 8 nodos aparentemente aleatorios (el equivalente a temas en Discourse) de aproximadamente 80,000 nodos en total. Estos son los números nid específicos en caso de que haya algún error de cálculo realmente extraño tipo Y2K:

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

Este mismo error siempre ocurre en los mismos nid cuando se vuelve a ejecutar el 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)

La única forma en que pude hacer que continuara fue modificando las condiciones 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};
...

Inspeccioné el primer nodo fallido, así como los nid anteriores y siguientes a cada lado en la base de datos de origen de Drupal, y no veo nada malo. El nid está configurado como clave primaria y tiene AUTO_INCREMENT, y el sitio original de Drupal funciona bien, por lo que no puede haber ningún problema fundamental con la integridad de la base de datos de origen.


Aparte del error anterior, estas son las limitaciones que tengo con el script:

  1. Enlaces permanentes: Parece que el script importador creará enlaces permanentes para las URL de nodos anteriores example.com/node/XXXXXXX. Pero también necesito mantener enlaces a comentarios específicos dentro de esos nodos, que tienen el formato: example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY es el mismo en ambas ocurrencias). El esquema de URL de Drupal no incluye el ID del nodo al que está asociado el comentario, mientras que Discourse sí (example.com/t/topic-keywords/XXXXXXX/YY), por lo que parece una complicación importante.

  2. Limitaciones de nombre de usuario: Drupal permite espacios en los nombres de usuario. Entiendo que Discourse no lo hace, al menos no permite que los nuevos usuarios los creen de esa manera. Esta publicación sugiere que el script importador “convertirá” automáticamente los nombres de usuario problemáticos, pero no veo ningún código para eso en /import_scripts/drupal.rb. Actualización: En realidad, parece que Discourse manejó esto automáticamente de la manera correcta.

  3. Usuarios baneados: Parece que el script importa a todos los usuarios, incluidas las cuentas baneadas. Probablemente podría agregar una condición bastante fácilmente a la selección SQL WHERE status = 1 para importar solo cuentas de usuario activas, pero no estoy seguro de si eso causaría problemas con la serialización de los registros. Por encima de todo, preferiría mantener esos nombres de cuenta previamente baneados con sus direcciones de correo electrónico asociadas permanentemente bloqueados para que los mismos usuarios problemáticos no se registren nuevamente en Discourse.

  4. Campos de perfil de usuario: ¿Alguien sabe si hay código de ejemplo en alguno de los otros importadores para importar campos de información personal de los perfiles de cuentas de usuario? Solo tengo un campo de perfil (“Ubicación”) que necesito importar.

  5. Avatares (no Gravatars): Parece un poco extraño que haya código en el importador de Drupal para importar Gravatars pero no para las imágenes de avatar de cuenta local mucho más utilizadas.

  6. Mensajes privados: Casi todos los foros de Drupal 7 probablemente usarán el módulo de terceros privatemsg (no hay funcionalidad oficial de PM en Drupal). El importador no admite la importación de PM. En mi caso, necesito importar alrededor de 1.5 millones de ellos.

Gracias de antemano por tu ayuda y por poner a disposición el script importador de Drupal.

Este conjunto de problemas es bastante normal para una importación grande. A quienquiera que se haya escrito no le importaron (quizás no lo suficiente como para notarlo) los problemas que describes.

Lo que suena como un error en Drupal o en la base de datos misma (no deberían ocurrir IDs duplicados). Probablemente habría modificado el script para probar y/o capturar el error cuando hay duplicados, pero tu manera funcionó (a menos que todavía haya más).

Puedes buscar en otros scripts de importación que crean permalinks de publicaciones. El import_id está en el PostCustomField de cada publicación.

Está en base.rb o en el sugeridor de nombres de usuario. Mayormente funciona y no hay mucho que puedas hacer para cambiarlo.

Probablemente no quieras hacer eso. El problema es que las publicaciones creadas por esos usuarios serán propiedad de system. Puedes buscar en otros scripts ejemplos de cómo desactivarlos. fluxbb tiene un script suspend_users, que debería ayudar.

fluxbb (en el que estoy trabajando ahora) hace eso. Simplemente agregas algo como esto al script de importación de usuarios:

          location: user['location'],

Los Gravatars son manejados por el núcleo de Discourse, por lo que el script no hace nada para importarlos; simplemente funciona. Puedes buscar en otros scripts la palabra “avatar” para encontrar ejemplos de cómo hacerlo.

Busca ejemplos. . . . ipboard tiene import_private_messages.

Gracias por la respuesta. No creo que sea un problema con la base de datos de Drupal, porque inspeccioné la base de datos de origen y no puedo encontrar claves nid duplicadas.

Ahhh, así que tiene esa funcionalidad fuera de drupal.rb. Ahora que estoy revisando el sitio de importación de prueba, en realidad parece que manejó muy bien las conversiones de nombres de usuario. ¡Gracias!

¿Cuál sería la forma más fácil de habilitar la importación de nombres de usuario unicode (sin convertirlos, es decir, mantener el nombre de usuario Narizón en lugar de convertirlo a Narizon) ?

Hice mi primera prueba del importador de Drupal en una instancia sin GUI web configurada, por lo que no había establecido la opción de Discourse para permitir nombres de usuario unicode. Si eso se hubiera configurado, ¿el importador lo habría respetado? ¿Cuál es la forma recomendada de habilitar esto para cuando ejecute mi migración de producción?

Y mientras tanto, para mi instancia de entorno de prueba actual, ¿existe algún comando rake para aplicar el nombre completo al nombre de usuario? (Ya activé prioritize username in ux, pero dado que mis usuarios de prueba están acostumbrados a Drupal, que solo admite nombres de usuario para iniciar sesión [no direcciones de correo electrónico], creo que sería mejor mantener sus nombres de usuario de producción, que al menos se mantuvieron en el campo nombre completo).

Probablemente

Puedes establecer la configuración del sitio al principio del script.

Creo que cambiar los nombres de usuario es una mala idea, pero si no te gustan, podrías cambiar lo que se pasa al generador de nombres de usuario.

Gracias, ¿quieres decir cambiarlos después de que se complete la importación?

Creo que me refiero a cambiarlos en absoluto, a menos que en el sistema antiguo los nombres de usuario fueran invisibles y solo vieran los nombres reales.

Si es este último el caso, entonces cambiaría el script para que el nombre de usuario sea su nombre real. El problema con esto es que si no conocen su dirección de correo electrónico, no podrán encontrar su cuenta.

Entendido. En el foro de Drupal solo hay nombres de usuario del sistema y no nombres reales separados. Y además, Drupal no permite iniciar sesión con la dirección de correo electrónico, solo con el nombre de usuario. Por eso es muy importante en mi caso mantener los nombres de usuario tanto como sea posible. (Todavía habrá algunos nombres de usuario convertidos, como los que tienen espacios). Así que necesito investigar cómo configurar los ajustes de Discourse al principio del script de importación.

Pero Discourse sí lo hace, así que si conocen su dirección de correo electrónico, pueden usarla para restablecer la contraseña, que es probablemente lo que deberías decirle a todos que hagan, ya que no puedes adivinar quién no puede adivinar su nombre de usuario, supongo.

Creo que lo que haría sería establecer SiteSetting.unicode_username=true en el script de importación y ejecutarlo de nuevo para ver si funciona. Podrías intentar probarlo en la consola de Rails para ver. Esto podría decirte:

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

Bueno, creo que eso no llamaría a la cosa que crea el nombre de usuario, así que necesitarás llamarla
UserNameSuggester.suggest(“Narizón”)

No. Eso todavía no te da un nombre de usuario unicode. Necesitarás encontrar el UserNameSuggester y ajustarlo, supongo.

Pero si realmente quieres cambiar los nombres de usuario, cambiarlos ahora en lugar de arreglar el script puede ser lo que quieras hacer. Necesitas asegurarte de que la forma en que lo haces actualiza el nombre de usuario en todas las publicaciones. Si estás usando una tarea rake, definitivamente hará eso.

¡Excelente, muchas gracias Jay! Intentaré esto la próxima vez que ejecute el importador.

No creo que debas molestarte:

Eso está en lib/user_name_suggester.rb, pero quizás quieras User.normalize_username

Efectivamente, tenías razón. Ni siquiera es un error en sí, resultó ser una forma extraña en que Drupal maneja los temas movidos, dejando una miga de pan en la categoría del tema anterior. Simplemente crea una fila duplicada en una de las muchas tablas que se extraen para formar lo que finalmente se convierte en un tema completo de Drupal. Así que parece que necesito averiguar cómo aplicar DISTINCT a solo una de las tablas que se seleccionan…

Sí. Es asombroso cómo cada importación es única, y de alguna manera la tuya es el primer foro que ha tenido ese problema (por supuesto, muchas personas podrían haber resuelto el problema y no haber logrado enviar una PR con la actualización). ¿O tal vez ignoraron los errores?

Ajá. Sospecho que no es una función muy utilizada, cuando un hilo se mueve a una nueva categoría hay una casilla de verificación opcional para dejar un enlace “Movido a…” en la categoría antigua.

El duplicado ofensivo está en la columna nid de forum_index. Así que parece que puedo arreglarlo con un GROUP BY nid, ¿verdad?

        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 prometedor, porque cuando ejecuto la consulta con el GROUP BY nid hay 8 filas menos.

Eso podría funcionar. Pensaría que habría algún valor en esa tabla que indicara que se había movido y que solo se pudieran seleccionar aquellas sin ese valor.

Esa sería definitivamente la forma más lógica de diseñarlo. Supongo que es una cosa de Drupal…

Lo único que hace es cambiar el tid (ID de categoría). Eso sigue el estilo que he aprendido durante esta terrible experiencia con la base de datos de Drupal. No sé nada sobre diseño de bases de datos, pero tengo la impresión de que puedes almacenar explícitamente los datos, o bien dejar algunas cosas implícitas y luego descubrirlas mediante lógica programática; Drupal parece caer de lleno en este último campo.

Bueno, parece que casi lo tengo. Muchas gracias a Jay por la orientación.

Gracias, eso fue clave. En realidad fue tan sencillo como copiar la parte del permalink del propio script de importación de Drupal y modificarlo para que funcione con publicaciones en lugar de temas:

    ## Añadí permalinks para cada enlace de comentario (respuesta) de Drupal: /comment/DÍGITOS#comment-DÍGITOS
    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}" # La parte #comment-DÍGITOS rompe el permalink y no es necesaria
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "La creación del permalink falló para cid #{post.id}"
      end
    end

Estuve atascado un buen rato con mi intento original, que incluía la parte relativa de la página #comment-DÍGITOS del enlace original de Drupal, lo cual rompe completamente el permalink en Discourse. Luego me di cuenta de que, por supuesto, la parte # de un enlace no se envía realmente al servidor web y solo era necesaria en Drupal para hacer que la página se desplazara hasta la sección donde se encontraba el comentario específico. Así que funciona perfectamente sin eso en Discourse, incluso si se accede desde una página web externa con un antiguo enlace /comment/DDDDDD#comment-DDDDD; simplemente se ve así en Discourse: /comment/DDDDDD/t/título-del-tema-palabras/123456/X, y la barra de URL muestra algo como: /t/título-del-tema-palabras/123456/X#comment-DDDDD. Parece no importarle la parte falsa #comment-DDDDD.

Para algunos foros, sospecho que la función postprocess_posts del importador estándar de Drupal podría ser suficiente. Cabe señalar que debe ajustarse para cada foro; hay un reemplazo de expresión regular bastante descuidado y codificado en el código para site.comcommunity.site.com. Pero después de ajustarlo, hace un buen trabajo reescribiendo los enlaces internos del foro de nodos → temas, así como de comentarios → respuestas. Sin embargo, tengo un número considerable de sitios web externos que enlazan a comentarios individuales (respuestas) en mi foro, y vale la pena conservar esos enlaces. Además, Google indexa la mayoría de los 1,7 millones de URLs /comment-DDDDD, y probablemente me afectaría el ranking si todas desaparecieran. Espero que no cause problemas en Discourse tener alrededor de 2 millones de permalinks.


Muchas gracias, saqué esa función casi sin modificaciones, solo tuve que ajustar algunos nombres de columnas. Funciona genial.

  def suspend_users
    puts '', "actualizando usuarios baneados"

    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, "baneado durante la importación inicial")
          banned += 1
        else
          puts "Fallo al suspender al usuario #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "No encontrado: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

¡También funcionó! Tuve que lidiar con el esquema de base de datos difuso de Drupal y usar LEFT JOIN profile_value location ON users.uid = location.uid para correlacionar otra tabla que contiene los datos del perfil, pero es muy genial que sea tan fácil añadirlo en el lado de Discourse. Cabe mencionar que este proceso se ejecuta aproximadamente un 50% más lento que el estándar; sospecho que se debe al LEFT JOIN. Pero puedo vivir con ello, ya que solo tengo alrededor de 80.000 usuarios.


Esto fue bastante difícil, una vez más debido al esquema de base de datos fragmentado de Drupal. Terminé usando jforum.rb como base, con un poco de ayuda del importador de Vanilla también. El script original era bastante paranoico al verificar en cada paso de cada variable para asegurarse de que el nombre del archivo del avatar no fuera nulo, así que eliminé la mayoría de esas comprobaciones para hacer el código menos desordenado. Lo peor que puede pasar es que el script se pueda caer, pero con la consulta SQL que usé, no creo que ni siquiera eso pueda salir mal.

  def import_users
    puts "", "importando usuarios"

    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

Después de tu ayuda pagada con la consulta SQL, terminé intentando hackearlo en los scripts de Discuz, IPboard y Xenforo. Seguí tropezando con callejones sin salida con cada uno; me quedé más cerca con el modelo de Discuz, que parece tener un esquema de base de datos muy similar, pero no pude superar un error con la variable de instancia @first_post_id_by_topic_id. Tras un montón de ensayo y error, finalmente me di cuenta de que estaba mal inicializada al principio del script de Discuz (intenté ponerla en la misma ubicación en el script de Drupal) y esto finalmente lo solucionó:

  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 '', 'creando mensajes privados'

	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 el título desde la tabla 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 los usuarios que forman parte de este mensaje privado.
          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? # ¿mensaje privado contigo mismo?
            skip = true
            puts "Saltando pm:#{m['id']} debido a que no hay destino"
          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 "El hilo del mensaje padre pm:#{thread_id} no existe. Saltando #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# buscar el primer id de pm para la serie 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

Oh, y para la mayoría de estas consultas también requiere ejecutar esto en el contenedor de MySQL para desactivar una comprobación de seguridad SQL de modo estricto:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Otra cosa que me di cuenta de que faltaba eran unos pocos miles de nodos de Drupal de tipo poll (encuesta). Primero intenté simplemente incluir WHERE type = 'forum' OR type = 'poll' en la función import_topics, pero hay cosas realmente extrañas ocurriendo en la base de datos original de Drupal que hacen que se pierda muchos de ellos. Así que terminé copiando import_topics en una nueva función import_polls:

    def import_poll_topics
    puts '', "importando temas de encuestas"

    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: "### Puedes ver los resultados archivados de la encuesta en 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

No me importa demasiado importar los resultados reales de la encuesta, y requeriría reescribir todo el algoritmo que usa Drupal para sumar todos los votos y eliminar duplicados. Principalmente solo quiero importar los comentarios de seguimiento en el hilo de la encuesta. Pero por si acaso alguien quiere ver los resultados originales de la encuesta, hice que escribiera un enlace directo al nodo original del foro en Wayback Machine.


Así que el código no es para nada elegante y probablemente no sea muy eficiente, pero para un trabajo de una sola vez debería hacer el trabajo.

Perdón por los muros de código; avísame si eso molesta a alguien y puedo moverlos a una URL de pastebin.

Y así es como van la mayoría. Este tema es un gran ejemplo para que alguien más lo siga (dado que comience con un conjunto de habilidades similar al tuyo).

¿Tienes una estimación de cuánto tiempo dedicaste a tus personalizaciones?

¡Felicidades!

¡Gracias Jay! Aprecio el ánimo.

Uf, preferiría no pensar en eso. :stuck_out_tongue_winking_eye: Probablemente fueron más de 15 o 20 horas después de que me pusiste en el camino correcto con la consulta SQL.

Me gustaría que me dieras tu opinión sobre esto si tienes alguna idea:

Tomó alrededor de 70 horas hacer una prueba completa con datos de producción en un VPS muy potente. Me gustaría que mis usuarios interactuaran nuevamente lo antes posible, incluso si la importación de publicaciones y mensajes privados aún está incompleta. O una idea alternativa que se me ocurrió sería deshabilitar la función preprocess_posts, que también modifiqué en gran medida con reemplazos adicionales de expresiones regulares gsub y también para pasar todas las publicaciones y mensajes privados a través de Pandoc con uno o dos comandos diferentes dependiendo de si la publicación original era marcado Textile o HTML puro. Si deshabilito toda la rutina preprocess_posts, probablemente reduciría el tiempo de importación casi a la mitad, y luego podría agregar todo ese material de formato a la sección postprocess_posts una vez que se importen todos los datos sin procesar. Pero la desventaja es que, después del hecho, no podría acceder fácilmente a la columna de la base de datos original que muestra el formato de origen (Textile o HTML) para cada publicación, lo cual es una condición para mi manipulación de Pandoc. ¿O podría agregar un campo personalizado a cada publicación etiquetándola como textile o html y luego recuperarla más tarde durante el post-procesamiento? No sé, solo estoy pensando en voz alta.

Cuando ejecutes el script de importación de nuevo solo con los datos nuevos, se ejecutará mucho más rápido, ya que no importará los datos de nuevo. Por lo tanto, solo tardará unas pocas horas. Y cada ejecución posterior será más rápida, ya que habrá menos datos que importar.

Luego, puedes acelerar eso modificando las consultas para que devuelvan solo datos más recientes que una cierta hora. La mayoría de los scripts que he tocado tienen una configuración import_after para este propósito (pero también para permitir un desarrollo más rápido importando un pequeño subconjunto de los datos).