Крупная миграция форума Drupal: ошибки импортера и ограничения

Привет! Эта тема дает некоторый контекст о миграции, которую я медленно планирую и тестирую. В прошлую пятницу я наконец-то попробовал импортер Drupal на тестовом VPS, используя комбинацию этого и этого. Импортер все еще работает, пока я пишу это, поэтому я еще не смог протестировать функциональность тестового сайта, но он почти завершится.

Самая большая проблема, с которой я сталкиваюсь, — это ошибка «дублирующееся значение ключа» для 8 apparently случайных узлов (аналогов тем в Discourse) из примерно 80 000 узлов. Вот конкретные номера nid, на случай если здесь замешан какой-то очень странный баг, похожий на Y2K:

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

Та же ошибка всегда возникает на этих же nid при повторном запуске импортера:

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)

Единственный способ заставить его продолжить работу — это взломать условия 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};
...

Я проверил первый неудачный узел, а также предыдущий и следующий nid по обе стороны от него в исходной базе данных Drupal, но не нашел ничего неправильного. nid установлен как первичный ключ и имеет AUTO_INCREMENT, и исходный сайт Drupal работает нормально, поэтому не может быть никакой фундаментальной проблемы с целостностью исходной базы данных.


Помимо вышеупомянутого бага, вот ограничения, с которыми я сталкиваюсь в скрипте:

  1. Постоянные ссылки: Похоже, что скрипт импортера создаст постоянные ссылки для бывших URL узлов example.com/node/XXXXXXX. Но мне также нужно сохранить ссылки на конкретные комментарии внутри этих узлов, которые имеют формат: example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY одинаково в обоих случаях). Схема URL Drupal не включает ID узла, с которым связан комментарий, тогда как в Discourse он есть (example.com/t/topic-keywords/XXXXXXX/YY), так что это выглядит как серьезное осложнение.

  2. Ограничения имен пользователей: Drupal позволяет использовать пробелы в именах пользователей. Я понимаю, что Discourse не позволяет этого делать, по крайней мере, новые пользователи не могут создавать такие имена. Этот пост предполагает, что скрипт импортера автоматически «преобразует» проблемные имена пользователей, но я не вижу кода для этого в /import_scripts/drupal.rb. Обновление: На самом деле кажется, что Discourse обработал это автоматически правильным образом.

  3. Заблокированные пользователи: Похоже, что скрипт импортирует всех пользователей, включая заблокированные учетные записи. Я мог бы довольно легко добавить условие в выборку SQL WHERE status = 1, чтобы импортировать только активные учетные записи пользователей, но я не уверен, вызовет ли это проблемы с сериализацией записей. Прежде всего, я бы предпочел навсегда заблокировать ранее заблокированные имена учетных записей с их связанными адресами электронной почты, чтобы те же проблемные пользователи не могли зарегистрироваться снова в Discourse.

  4. Поля профиля пользователя: Знает ли кто-нибудь, есть ли пример кода в одном из других импортеров для импорта полей личной информации из профилей учетных записей пользователей? У меня есть только одно поле профиля («Местоположение»), которое нужно импортировать.

  5. Аватары (не Gravatars): Казалось бы странно, что в импортере Drupal есть код для импорта Gravatars, но не для гораздо более часто используемых локальных изображений аватаров учетных записей.

  6. Личные сообщения: Почти все форумы Drupal 7, вероятно, будут использовать сторонний модуль privatemsg (официальной функциональности PM в Drupal нет). Импортер не поддерживает импорт личных сообщений. В моем случае мне нужно импортировать около 1,5 млн из них.

Заранее спасибо за вашу помощь и за то, что сделали скрипт импортера Drupal доступным.

Этот набор проблем вполне типичен для крупного импорта. Тот, для кого это было написано, не уделял внимания (возможно, не заметил) проблемам, которые вы описываете.

Это звучит как ошибка в Drupal или самой базе данных (дубликаты идентификаторов не должны возникать). Я бы, скорее всего, изменил скрипт, чтобы проверять и/или перехватывать ошибку при наличии дубликатов, но ваш вариант сработал (если только их не осталось больше).

Вы можете посмотреть другие скрипты импорта, которые создают постоянные ссылки на посты. import_id находится в PostCustomField каждого поста.

Это либо в base.rb, либо в генераторе предложений по именам пользователей. В основном это работает автоматически, и мало что можно изменить.

Вам, вероятно, не стоит этого делать. Проблема в том, что посты, созданные этими пользователями, будут принадлежать system. Вы можете посмотреть другие скрипты, чтобы узнать, как их деактивировать. У fluxbb есть скрипт suspend_users, который должен помочь.

fluxbb (над которым я сейчас работаю) делает это. Вам просто нужно добавить что-то подобное в скрипт импорта пользователей:

          location: user['location'],

Gravatars обрабатываются ядром Discourse, поэтому скрипт ничего не делает для их импорта; это работает автоматически. Вы можете выполнить поиск по другим скриптам по слову “avatar”, чтобы найти примеры того, как это сделать.

Ищите примеры. . . . У ipboard есть import_private_messages.

Спасибо за ответ. Я не думаю, что проблема в базе данных Drupal, потому что я проверил исходную базу данных и не нашел дубликатов ключей nid.

Ах, значит, эта функциональность находится за пределами drupal.rb. Теперь, когда я изучаю тестовый сайт импорта, оказывается, что преобразование имен пользователей было выполнено очень хорошо. Спасибо!

Какой самый простой способ включить импорт юникодных имён пользователей (без их преобразования, то есть оставить имя Narizón, а не конвертировать его в Narizon)?

Я провёл первый тест импортера Drupal на экземпляре, где не был настроен веб-интерфейс, поэтому я не установил опцию Discourse для разрешения юникодных имён пользователей. Если бы эта опция была включена, импортер бы её учёл? Какой рекомендуемый способ включить это для миграции в продакшн?

А пока для моего текущего тестового экземпляра есть ли какая-то команда rake, чтобы применить полное имя к имени пользователя? (Я уже активировал prioritize username in ux, но так как мои тестовые пользователи привыкли к Drupal, где для входа поддерживаются только имена пользователей (а не адреса электронной почты), я считаю, что лучше сохранить их производственные имена пользователей, которые, по крайней мере, сохранились в поле полного имени.)

Вероятно?

Вы можете установить настройку сайта в начале скрипта.

Я считаю, что изменение имён пользователей — плохая идея, но если они вам не нравятся, вы можете изменить то, что передаётся в генератор имён пользователей.

Спасибо, вы имеете в виду изменение их после завершения импорта?

Я думаю, что речь идет о том, чтобы вообще ничего не менять, если в старой системе имена пользователей были скрыты и отображались только реальные имена.

Если это так, то я бы изменил скрипт, чтобы в качестве имени пользователя использовалось реальное имя. Проблема в том, что если они не знают свой адрес электронной почты, то не смогут найти свою учетную запись.

Понял. На форуме Drupal есть только системные имена пользователей, и отдельные реальные имена не предусмотрены. Кроме того, в Drupal вход возможен только по имени пользователя, а не по адресу электронной почты. Поэтому в моём случае крайне важно максимально сохранить имена пользователей. (Тем не менее, некоторые имена всё же потребуют преобразования, например, те, что содержат пробелы.) Мне нужно изучить, как настроить параметры Discourse в самом начале скрипта импорта.

Но Discourse позволяет, поэтому, если они знают свой адрес электронной почты, они могут использовать его для сброса пароля. Вероятно, именно это вы и должны посоветовать всем сделать, поскольку вы не можете угадать, кто не помнит своё имя пользователя, я полагаю.

Я бы поступил так: установил SiteSetting.unicode_username=true в скрипте импорта и запустил его снова, чтобы проверить, работает ли это. Возможно, стоит протестировать это в консоли Rails. Это может показать следующее:

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

Хотя, думаю, это может не вызвать функцию создания имени пользователя, поэтому вам нужно будет вызвать её явно:

  UserNameSuggester.suggest("Narizón")

Нет. Это всё ещё не даст вам имя пользователя в юникоде. Придётся, я полагаю, найти UserNameSuggester и внести в него изменения.

Но если вы действительно хотите изменить имена пользователей, возможно, лучше сделать это сейчас, а не исправлять скрипт. Главное — убедиться, что при изменении имена пользователей обновляются во всех сообщениях. Если вы используете задачу Rake, она наверняка это сделает.

Отлично, большое спасибо, Джей! Я попробую это в следующий раз при запуске импортера.

Думаю, не стоит беспокоиться:

Это находится в lib/user_name_suggester.rb, но, возможно, вам нужно User.normalize_username

Действительно, вы были правы. Это даже не баг как таковой — оказалось, что это странное поведение Drupal при перемещении тем, когда в прежней категории темы остаётся «хлебная крошка». Система просто создаёт дублирующую строку в одной из множества таблиц, которые затем объединяются в итоговую тему Drupal. Похоже, мне нужно разобраться, как применить DISTINCT только к одной из выбираемых таблиц…

Да. Удивительно, как каждый импорт уникален, и каким-то образом ваш форум стал первым, у кого возникла эта проблема (конечно, многие могли решить её и не смогли отправить PR с обновлением). Или, возможно, они просто игнорировали ошибки?

Ага. Я подозреваю, что это не очень часто используемая функция: когда поток перемещается в новую категорию, есть необязательный чекбокс, чтобы оставить ссылку «Перемещено в…» в старой категории.

Дубликат, вызывающий проблему, находится в столбце nid таблицы forum_index. Похоже, я могу исправить это, добавив GROUP BY nid, верно?

        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

Выглядит многообещающе, потому что при выполнении запроса с GROUP BY nid строк стало на 8 меньше.

Возможно, это сработает. Я думаю, что в этой таблице была бы какая-то ценность, если бы в ней указывалось, что запись была перемещена, и вы могли бы отфильтровать только те, у которых этого значения нет.

Это, безусловно, был бы самый логичный способ её проектирования. Думаю, это особенность Drupal…

Единственное, что она делает, — это изменяет tid (ID категории). Это соответствует стилю, который я усвоил во время этой истории с базой данных Drupal. Я ничего не знаю о проектировании баз данных, но у меня складывается впечатление, что либо данные хранятся явно, либо некоторые вещи остаются неявными, а затем вычисляются с помощью программной логики; Drupal, похоже, полностью относится ко второй категории.

Ну, похоже, я почти добрался до цели. Большое спасибо Джей за помощь.

Спасибо, это было ключевым моментом. На самом деле всё оказалось так просто: нужно было просто скопировать часть скрипта импорта Drupal, отвечающую за постоянные ссылки, и адаптировать её для работы с сообщениями вместо тем:

    ## Я добавил постоянные ссылки для каждого комментария (ответа) Drupal: /comment/ЦИФРЫ#comment-ЦИФРЫ
    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}" # Часть #comment-ЦИФРЫ ломает постоянную ссылку и не нужна
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Создание постоянной ссылки не удалось для cid #{post.id}"
      end
    end

Я какое-то время застревал на своей первоначальной попытке, которая включала относительную часть страницы #comment-ЦИФРЫ из исходной ссылки Drupal, что полностью ломало постоянную ссылку в Discourse. Затем я понял, что, конечно, часть # ссылки на самом деле не передаётся на веб-сервер и была нужна Drupal только для прокрутки страницы к месту конкретного комментария. Поэтому в Discourse всё работает отлично и без неё: даже если перейти по старой ссылке с внешнего сайта вида /comment/YYYYYY#comment-YYYYY, в Discourse она будет выглядеть так: /comment/YYYYYY/t/topic-title-words/123456/X, а в адресной строке будет показано: /t/topic-title-words/123456/X#comment-YYYYYY. Похоже, что часть #comment-YYYYYY игнорируется.

Для некоторых форумов, возможно, стандартная функция postprocess_posts импортера Drupal будет достаточной. Стоит отметить, что её нужно адаптировать под каждый форум: там есть довольно небрежное жестко закодированное регулярное выражение для замены site.comcommunity.site.com. Но после этой правки она хорошо переписывает внутренние ссылки форума: узлы → темы, а также комментарии → ответы. Однако у меня довольно много внешних сайтов, ссылающихся на отдельные комментарии (ответы) на моём форуме, и важно сохранить эти ссылки. Кроме того, Google проиндексировал большинство из 1,7 млн URL-адресов вида /comment-YYYYYY, и если они все исчезнут, это, вероятно, навредит моему рейтингу. Надеюсь, что наличие ~2 млн постоянных ссылок не вызовет проблем для Discourse?


Большое спасибо, я взял эту функцию почти без изменений, пришлось лишь подправить несколько названий столбцов. Работает отлично.

  def suspend_users
    puts '', "обновление заблокированных пользователей"

    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, "заблокирован во время первоначального импорта")
          banned += 1
        else
          puts "Не удалось приостановить пользователя #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "Не найден: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

Тоже сработало! Мне пришлось разобраться с разрозненной схемой базы данных Drupal и использовать LEFT JOIN profile_value location ON users.uid = location.uid, чтобы связать другую таблицу, содержащую данные профиля, но очень круто, что на стороне Discourse это так легко добавить. Стоит отметить, что этот процесс работает примерно на 50% медленнее стандартного; я подозреваю, что это из-за LEFT JOIN. Но я могу с этим жить, так как у меня всего около 80 тысяч пользователей.


Это было довольно сложно, опять же из-за разрозненной схемы базы данных Drupal. В итоге я использовал jforum.rb как основу с небольшой помощью импортера Vanilla. Исходный скрипт был чрезмерно параноидальным в проверке каждой переменной, чтобы убедиться, что имя файла аватара не пустое, поэтому я убрал большинство этих проверок, чтобы код был менее загроможденным. Худшее, что может случиться — скрипт может упасть, но с SQL-запросом, который я использовал, я думаю, что даже этого не произойдет.

  def import_users
    puts "", "импорт пользователей"

    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

После вашей платной помощи с SQL-запросом я в итоге попытался внедрить это в скрипты для Discuz, IPboard и Xenforo. Я постоянно натыкался на тупики с каждым из них; ближе всего я подошел к модели Discuz, которая, похоже, имеет очень похожую схему базы данных, но не мог пройти дальше ошибки с переменной экземпляра @first_post_id_by_topic_id. После множества проб и ошибок я наконец понял, что она была неправильно инициализирована в начале скрипта Discuz (я попытался поместить её в то же место в скрипте Drupal), и это наконец исправило ситуацию:

  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 '', 'создание личных сообщений'

	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'])
				# найти заголовок из таблицы списка
				#          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

          # Найти пользователей, участвующих в этом личном сообщении.
          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? # ЛС самому себе?
            skip = true
            puts "Пропуск ЛС:#{m['id']} из-за отсутствия получателя"
          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 "Родительское сообщение потока ЛС:#{thread_id} не существует. Пропуск #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# поиск первого ID ЛС для серии сообщений
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

О, и для большинства этих запросов также требуется выполнить следующую команду в контейнере MySQL, чтобы отключить строгую проверку SQL:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Ещё я заметил, что отсутствовали несколько тысяч узлов Drupal типа poll (опросы). Сначала я попытался просто добавить WHERE type = 'forum' OR type = 'poll' в функцию import_topics, но в исходной базе данных Drupal происходит что-то действительно странное, из-за чего многие из них пропускаются. Поэтому я в итоге скопировал import_topics в новую функцию import_polls:

    def import_poll_topics
    puts '', "импорт тем опросов"

    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: "### Вы можете увидеть архивные результаты опроса в 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

Мне не слишком важно импортировать сами результаты опросов, и для этого пришлось бы переписывать весь алгоритм подсчета голосов и устранения дубликатов, используемый Drupal. Я в основном хочу импортировать последующие комментарии в теме опроса. Но на всякий случай, если кто-то захочет увидеть оригинальные результаты опроса, я сделал так, чтобы скрипт записывал прямую ссылку на оригинальный узел форума в Wayback Machine.


Итак, код совсем не элегантный и, вероятно, не очень эффективный, но для одноразового решения он должен справиться с задачей.

Извините за стены кода, дайте знать, если это кого-то раздражает, и я могу переместить их на URL pastebin.

И именно так обычно всё происходит. Эта тема — отличный пример для того, кто захочет последовать вашему пути (при условии, что он начинает с аналогичным уровнем навыков).

У вас есть оценка того, сколько времени ушло на вашу кастомизацию?

Поздравляю!

Спасибо, Джей! Ценю твою поддержку.

Уф, я бы предпочёл не думать об этом. :stuck_out_tongue_winking_eye: После того как ты указал мне верное направление с SQL-запросом, это, наверное, заняло от 15 до 20 часов.

Хотел бы обсудить с тобой эту тему, если у тебя есть какие-то мысли:

На полный пробный запуск с производственными данными на очень мощном VPS ушло около 70 часов. Я бы хотел, чтобы мои пользователи снова начали взаимодействовать как можно скорее, даже если импорт постов и личных сообщений ещё не завершён. Или же у меня есть другая идея: отключить функцию preprocess_posts, которую я тоже значительно доработал, добавив дополнительные замены через gsub с регулярными выражениями, а также пропустить все посты и личные сообщения через Pandoc, используя одну из двух команд в зависимости от того, был ли исходный пост размечен в Textile или это чистый HTML. Если я отключу весь цикл preprocess_posts, время импорта, вероятно, сократится почти вдвое, а затем я смогу добавить всю эту логику форматирования в секцию postprocess_posts после того, как все исходные данные будут импортированы. Но минус в том, что после этого я не смогу легко получить доступ к исходному столбцу базы данных, который показывает формат источника (Textile или HTML) для каждого поста, что является условием для моих манипуляций в Pandoc. Или же я могу добавить пользовательское поле к каждому посту, помечая его как textile или html, а затем извлекать эту информацию позже при постобработке? Не знаю, просто мысли вслух.

Когда вы снова запустите скрипт импорта только с новыми данными, он выполнится намного быстрее, так как данные не будут импортироваться повторно. Таким образом, это займёт всего несколько часов. Каждый последующий запуск будет ещё быстрее, поскольку импортировать придётся меньше данных.

Вы можете ускорить этот процесс, изменив запросы так, чтобы они возвращали только данные, созданные после определённого времени. В большинстве скриптов, с которыми я работал, есть настройка import_after, предназначенная именно для этого (а также для ускорения разработки за счёт импорта небольшого подмножества данных).