大規模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があります。

「いいね!」 1

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

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

「いいね!」 1

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タスクを使用している場合は、確実に更新されます。

「いいね!」 1

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

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

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

「いいね!」 1

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

「いいね!」 1

ええ。インポートごとに異なるのは驚くべきことです。そして、あなたのフォーラムがその問題に遭遇した最初のフォーラムであるかのように(もちろん、多くの人が問題を解決し、アップデートで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はその後半に完全に属しているようです。

「いいね!」 2

Well, it looks like I’m almost there. Thanks a lot to Jay for the guidance.

Thanks, this was key, it was actually as simple as copying the permalink part of the Drupal import script itself and changing it to run on posts instead of topics:

    ## I added permalinks for each Drupal comment (reply) link: /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}" # The #comment-DIGITS part breaks the permalink and isn't needed
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Permalink creation failed for cid #{post.id}"
      end
    end

I was stuck for a while with my original attempt that included the relative page #comment-DIGITS part of the original Drupal link, which completely breaks the permalink in Discourse. then I realized that of course the # part of a link doesn’t actually get passed to the webserver and was only needed for Drupal to make it scroll to the part of the page where the specific comment was located. So it works fine without that in Discourse even if coming from an external web page with an old /comment/YYYYYY#comment-YYYYY link, it simply looks like this in Discourse: /comment/YYYYYY/t/topic-title-words/123456/X and the URL bar shows like: /t/topic-title-words/123456/X#comment-YYYYYY , it doesn’t appear to care about the bogus #comment-YYYYYY part.

For some forums I suspect that the stock Drupal importer postprocess_posts function might actually be enough. It should be noted that it needs to be adjusted for each forum, there’s a rather sloppy hard-coded regexp replace for site.comcommunity.site.com. But after adjusting that it does a good job of rewriting internal forum links for nodes → topics as well as comments → replies. But I do have a fair number of external websites linking to individual comments (replies) on my forum and it’s worth conserving those. Plus Google indexes most of the 1.7M /comment-YYYYYY URLs and it would probably hurt my ranking if those all disappeared. I hope it won’t cause any problems for Discourse to have ~2M permalinks though?


Thanks a lot, I lifted that function almost without modifications, just had to adjust a few column names. Works great.

  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

Also worked! I did have to deal with Drupal’s diffuse DB schema and LEFT JOIN profile_value location ON users.uid = location.uid to correlate another table that contains the profile data, but very cool that it’s so easy to add on the Discourse side of things. It’s worth noting that this process runs about 50% slower than stock, I suspect it’s due to the LEFT JOIN. But I can live with it, as I only have about 80K users.


This was fairly hard, once again due to Drupal’s disjointed database schema. I ended up using jforum.rb as the basis with a little help from the Vanilla importer too. The original script was rather paranoid with checking at every single variable pass to make sure the avatar filename isn’t null, so I removed most of those checks to make the code less messy. The worst that can happen is that the script could crash, but with the SQL query I used I don’t think even that could go wrong.

  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

After your paid help with the SQL query I ended up trying to hack it into the script for Discuz, IPboard, and Xenforo. I kept getting hitting dead ends with each one, I got closest with the Discuz model which appears to have a very similar database schema, but I couldn’t get past a bug with the @first_post_id_by_topic_id instance variable. After tons of trial and error I finally realized that it was improperly initialized at the beginning of the Discuz script (I tried to put it in the same location in the Drupal script) and this finally fixed it:

  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'])
				# find the title from list table
				#          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

          # Find the users who are part of this 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 with yourself?
            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
# search for first pm id for the series of 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, and for most of these queries it also requires running this in the MySQL container to disable a strict mode SQL sanity check:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Another thing I realized was missing were a few thousand Drupal nodes of type poll . I first tried to just include WHERE type = 'forum' OR type = 'poll' in the import_topics function, but there is some seriously janky stuff going on in the original Drupal database that causes it to miss many of them. So I ended up copying the import_topics into a new import_polls function:

    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

I don’t care too much about importing the actual poll results, and it would require re-coding the entire algorithm that Drupal uses to tally up all the votes and eliminates duplicates. I mainly just want to import the followup comments in the poll thread. But just in case anyone wants to see the original poll results I made it write out a direct link to the original forum node in the Wayback Machine.


So the code is not at all elegant and probably isn’t very efficient, but for a one-shot deal that should get the job done.

Sorry for the walls of code, let me know if that irritates anyone and I can move them to a pastebin URL.

「いいね!」 1

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

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

おめでとうございます!

「いいね!」 1

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

うーん、それは考えたくありません。 :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 設定があります(また、データの小さなサブセットをインポートすることで、より高速な開発を可能にします)。

「いいね!」 1