大規模Drupalフォーラム移行、インポータエラーと制限

こんにちは。このトピックは、私がゆっくりと計画とテストを進めている移行の背景情報を提供しています。このこのを組み合わせて、先週金曜日にテスト環境のVPSでDrupalインポーターを試しました。インポーターは私がこれを書いている間も実行中なので、テストサイトの実際の機能はまだテストできていませんが、もうすぐ完了します。

私が直面している最大の課題は、約80,000ノード(Discourseのトピックに相当)のうち、8つのランダムに見えるノードでの「重複キー値」エラーです。念のため、Y2Kのような奇妙な数学的バグが関与している可能性も考慮して、問題のnid番号を以下に示します。

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 は両方の箇所で同じです)。Drupal の URL スキームには、コメントが関連付けられているノード ID が含まれていませんが、Discourse には含まれています (example.com/t/topic-keywords/XXXXXXX/YY)。これは大きな複雑さをもたらすようです。

  2. ユーザー名制限: Drupal ではユーザー名にスペースを含めることができます。Discourse では、少なくとも新しいユーザーがそのように作成することはできないと理解しています。この投稿では、インポーター スクリプトが問題のあるユーザー名を自動的に「変換」すると示唆されていますが、/import_scripts/drupal.rb にそのコードは見当たりません。 更新: 実際には、Discourse がこれを自動的に正しく処理したようです。

  3. 禁止ユーザー: スクリプトは、禁止されているアカウントを含むすべてのユーザーをインポートするようです。SQL の選択条件に WHERE status = 1 を追加して、アクティブなユーザーアカウントのみをインポートするように簡単に条件を追加できるかもしれませんが、それがレコードのシリアライゼーションに問題を引き起こすかどうかはわかりません。何よりも、以前禁止されていたアカウント名を、関連するメールアドレスとともに永久にブロックし、同じ問題のあるユーザーが Discourse で再度サインアップしないようにしたいと考えています。

  4. ユーザープロファイルフィールド: 個人情報フィールドをユーザープロファイルからインポートする例が、他のインポーターにあるかどうか、どなたかご存知ですか?インポートする必要があるプロファイルフィールドは「場所」の1つだけです。

  5. アバター(Gravatar以外): Drupal インポーターに Gravatar をインポートするコードがあり、より一般的に使用されているローカルアカウントのアバター画像用のコードがないのは奇妙な気がします。

  6. プライベートメッセージ: ほとんどの Drupal 7 フォーラムでは、サードパーティのprivatemsgモジュールを使用していると思われます(公式のDrupal PM機能はありません)。インポーターは PM のインポートをサポートしていません。私の場合は、約150万件の PM をインポートする必要があります。

ご協力と Drupal インポーター スクリプトの提供に感謝いたします。

この一連の問題は、大規模なインポートではよくあることです。記述されている問題については、作成者は気にかけていなかったのでしょう(おそらく気づくほどではなかったのかもしれません)。

これはDrupalまたはデータベース自体のバグのように聞こえます(重複IDは発生しないはずです)。重複がある場合にエラーをテストまたはキャッチするようにスクリプトを変更したかもしれませんが、あなたの方法でうまくいきました(ただし、まだ他に問題がある場合は別です)。

投稿のパーマリンクを作成する他のインポートスクリプトを参照してください。import_idは各投稿のPostCustomFieldにあります。

base.rbまたはユーザーサジェスターのいずれかにあります。ほとんどの場合、それは機能しており、変更できることはあまりありません。

それはおそらく望ましくないでしょう。問題は、それらのユーザーによって作成された投稿がsystemによって所有されることです。それらを非アクティブ化する方法の例については、他のスクリプトを参照してください。fluxbbにはsuspend_usersスクリプトがあり、それが役立つはずです。

fluxbb(現在作業中)はそれを行います。インポートユーザー スクリプトに次のようなものを追加するだけです。

              location: user['location'],

GravatarはDiscourseコアによって処理されるため、スクリプトはそれらをインポートするために何も行いません。それは機能するだけです。他のスクリプトで「avatar」をgrepすると、それを行う方法の例が見つかります。

例を探してください。 . . . ipboardにはimport_private_messagesがあります。

返信ありがとうございます。Drupalデータベースの問題だとは思いません。なぜなら、ソースデータベースを調べましたが、重複したnidキーは見つかりませんでした。

ああ、つまりdrupal.rb以外にもその機能があるのですね。テストインポートサイトを調べてみると、実際にはユーザー名の変換を非常によく処理していたようです。ありがとうございます!

Unicodeユーザー名(つまり、ユーザー名をNarizonに変換するのではなく、Narizónのままにする)のインポートを有効にする最も簡単な方法は何でしょうか?

Drupalインポーターの最初のテストをWeb GUIが設定されていないインスタンスで実行したため、Unicodeユーザー名を許可するDiscourseオプションを設定していませんでした。もしそれが設定されていた場合、インポーターはそれを尊重したでしょうか?本番移行を実行する際にこれを有効にするための推奨される方法はありますか?

そして、現在のテストベッドインスタンスでは、rakeコマンドでフルネームをユーザー名に適用することはできますか?(prioritize username in uxはすでに有効にしましたが、テストユーザーはDrupalに慣れており、Drupalではログインにユーザー名のみ(メールアドレスではなく)をサポートしているため、本番のユーザー名を維持するのが最善だと思います。少なくともフルネームフィールドには維持されていました。)

おそらく?

スクリプトの冒頭でサイト設定を設定できます。

ユーザー名を変更するのは悪い考えだと思いますが、気に入らない場合は、ユーザー名ジェネレーターに渡されるものを変更できます。

インポート完了後に変更することを意味していますか?

古いシステムではユーザー名が表示されず、実名のみが表示されていた、という意味であれば、スクリプトを変更してユーザー名を実名にするべきです。問題は、メールアドレスを知らないとアカウントを見つけられないことです。

了解しました。Drupalフォーラムには、実際の名前ではなくシステムユーザー名のみが存在します。さらに、Drupalではメールアドレスでのログインは許可されておらず、ユーザー名でのみログインできます。そのため、私のケースでは、ユーザー名を可能な限り維持することが非常に重要です。(スペースを含む一部のユーザー名は変換されます。)そのため、インポートスクリプトの冒頭でDiscourseの設定方法を確認する必要があります。

しかし、Discourseはできます。そのため、メールアドレスを知っていれば、それを使ってパスワードをリセットできます。これはおそらく、誰がユーザー名を推測できないか推測できないかを考えると、全員にそうするように伝えるべきことでしょう。

インポートスクリプトでSiteSetting.unicode_username=trueを設定して、再度実行して動作するかどうかを確認するのが良いと思います。レールコンソールでテストして確認することもできます。これにより、次のようなことがわかります。

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

さて、これはユーザー名作成の機能呼び出しではないかもしれませんので、呼び出す必要があります。

  UserNameSuggester.suggest("Narizón")

いいえ。それでもユニコードユーザー名は取得できません。UserNameSuggesterを見つけて調整する必要があるでしょう。

しかし、本当にユーザー名を変更したいのであれば、スクリプトを修正するよりも今変更する方が良いかもしれません。変更する際には、すべての投稿でユーザー名が更新されるようにする必要があります。rakeタスクを使用している場合は、確実に更新されます。

素晴らしい、Jayさん、どうもありがとうございます! 次回インポーターを実行するときに試してみます。

気にする必要はないと思います。

それは lib/user_name_suggester.rb にありますが、おそらく User.normalize_username を使用したいでしょう。

やはり、あなたの言う通りでした。厳密にはバグというほどではなく、Drupalが移動されたトピックを処理する方法が奇妙で、元のトピックカテゴリにパンくずリストを残したことが判明しました。これにより、最終的に完全なDrupalトピックになるためにすべてがプルインされる多くのテーブルの1つに重複行が作成されるだけです。したがって、選択されるテーブルの1つにのみDISTINCTを適用する方法を見つける必要があるようです…

ええ。インポートごとに異なるのは驚くべきことです。そして、あなたのフォーラムがその問題に遭遇した最初のフォーラムであるかのように(もちろん、多くの人が問題を解決し、アップデートでPRを提出できなかったかもしれません)。あるいは、エラーを無視したのでしょうか?

なるほど。あまり一般的に使用されていない機能だと思いますが、スレッドが新しいカテゴリに移動されると、「移動先…」リンクを古いカテゴリに残すオプションのチェックボックスがあります。

問題の重複は、forum_indexnid 列にあります。したがって、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はその後半に完全に属しているようです。

まあ、ほぼ完了したようです。Jay さんの指導に心から感謝します。

ありがとうございます、これが決め手でした。実際には、Drupal のインポートスクリプト自体からパーマリンク部分をコピーし、トピックではなく投稿で実行するように変更するだけだったのです。

    ## Drupal コメント(返信)リンクごとにパーマリンクを追加しました:/comment/DIGITS#comment-DIGITS
    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-DIGITS の部分はパーマリンクを壊すので不要です
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Permalink creation failed for cid #{post.id}"
      end
    end

当初の試みでは、元の Drupal リンクの相対ページ部分である #comment-DIGITS を含めていたため、しばらく行き詰まっていました。これは Discourse においてパーマリンクを完全に壊してしまいます。その後、当然ながらリンクの # 部分はウェブサーバーに渡されず、特定のコメントがあるページ部分にスクロールするために Drupal だけで必要だったことに気づきました。そのため、Discourse ではその部分なしでも問題なく動作します。外部のウェブページから古い /comment/YYYYYY#comment-YYYYY リンクで来ても、Discourse 内では /comment/YYYYYY のように表示され、/t/topic-title-words/123456/X にリダイレクトされます。URL バーには /t/topic-title-words/123456/X#comment-YYYYYY のように表示されますが、無効な #comment-YYYYYY の部分は気にしていないようです。

一部のフォーラムでは、標準の Drupal インポーターの postprocess_posts 関数だけで十分かもしれません。ただし、各フォーラムに合わせて調整する必要があります。site.comcommunity.site.com に置き換えるためのかなり雑なハードコードされた正規表現置換があります。しかし、それを調整すれば、ノードをトピックに、コメントを返信に書き換える内部フォーラムリンクの処理はよく機能します。ただし、私のフォーラムには個々のコメント(返信)への外部ウェブサイトからのリンクが多数あり、それらを保存する価値があります。さらに、Google は 170 万の /comment-YYYYYY URL の大部分をインデックスしており、それらがすべて消えるとランキングに悪影響を与える可能性があります。Discourse に約 200 万のパーマリンクがあっても問題ないことを願っています。


ありがとうございます。その関数はほぼ変更なしに持ち上げることができました。いくつかの列名を調整するだけで済みました。非常にうまく機能しています。

  def suspend_users
    puts '', "updating banned users"

    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 during initial import")
          banned += 1
        else
          puts "Failed to suspend user #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "Not found: #{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 が原因だと推測しています。しかし、ユーザーが約 8 万人しかいないので、これで十分です。


これもかなり難しかったです。これもまた、Drupal の断片化したデータベーススキーマが原因でした。結局、jforum.rb をベースにし、Vanilla インポーターの少しの助けを借りて実装しました。元のスクリプトは、アバターファイル名が null にならないように、すべての変数パスでチェックを行うというかなり神経質なものでしたが、コードを整理するためにそれらのチェックのほとんどを削除しました。最悪の事態はスクリプトがクラッシュすることですが、使用した SQL クエリでは、それさえも問題ないと思います。

  def import_users
    puts "", "importing users"

    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 スクリプトの開始時に正しく初期化されていなかった(同じ場所に配置しようとしました)ことに気づき、これで修正されました。

  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 '', 'creating private messages'

	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? # 自分自身への pm?
            skip = true
            puts "Skipping pm:#{m['id']} due to no target"
          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 "Parent post pm thread:#{thread_id} doesn't exist. Skipping #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# 一連の pm の最初の pm 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',''));"


他にも、いくつかの数千の poll タイプの Drupal ノードが欠落していることに気づきました。当初は import_topics 関数に WHERE type = 'forum' OR type = 'poll' を追加しようとしたのですが、元の Drupal データベースで非常に厄介なことが起きており、多くのノードを見逃していました。そのため、import_topics を新しい import_polls 関数にコピーしました。

    def import_poll_topics
    puts '', "importing poll topics"

    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: "### You can see the archived poll results on the 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 内の元のフォーラムノードへの直接リンクを出力するようにしました。


したがって、コードは全くエレガントではなく、おそらく効率的でもありませんが、一度きりの作業としてはこれで十分でしょう。

コードの壁を貼って申し訳ありません。もし誰かに不快に思われたら、pastebin の URL に移動できるのでお知らせください。

そして、それらのほとんどはそうやって進みます。このトピックは、(あなたのスキルセットと同様のスキルセットから始めることを考えると)他の誰かが従うべき素晴らしい模範です。

カスタマイズに費やした時間の見積もりはありますか?

おめでとうございます!

ジェイさん、ありがとうございます!励みにします。

うーん、それは考えたくありません。 :stuck_out_tongue_winking_eye: SQLクエリで正しい道を示してもらってから、おそらく15〜20時間以上かかったでしょう。

もし何か考えがあれば、この件についてあなたの意見を聞かせてください。

非常に強力なVPSで、本番データを使用して完全な試行を実行するには約70時間かかりました。投稿やプライベートメッセージのインポートがまだ完了していなくても、できるだけ早くユーザーに再度操作してもらいたいと考えています。または、私が大幅に修正し、追加のgsub正規表現置換を行ったpreprocess_posts関数を無効にするという別のアイデアもありました。また、元の投稿がTextileマークアップか純粋なHTMLかに応じて、2つのコマンドのいずれかでPandocですべての投稿とプライベートメッセージを処理します。preprocess_postsルーチン全体を無効にすると、インポート時間はほぼ半分に短縮される可能性があり、その後、すべての生のデータがインポートされた後に、そのフォーマット処理をすべてpostprocess_postsセクションに追加できます。しかし、欠点は、後で各投稿のソースフォーマット(TextileまたはHTML)を示す元のデータベース列に簡単にアクセスできなくなることです。これはPandoc操作の条件です。または、各投稿にtextileまたはhtmlというラベルを付けたカスタムフィールドを追加し、後でポスト処理中にそれを取得することはできますか?どうでしょう、独り言です。

新しいデータのみでインポートスクリプトを再度実行すると、データが再度インポートされることはないため、はるかに高速に実行されます。したがって、数時間しかかかりません。インポートするデータが少なくなるため、その後の各実行はより高速になります。

その後、クエリを変更して、特定の時刻よりも新しいデータのみを返すことで、これを高速化できます。私が触ったほとんどのスクリプトには、この目的のためだけの import_after 設定があります(また、データの小さなサブセットをインポートすることで、より高速な開発を可能にします)。