هجرة كبيرة لمنتديات Drupal، أخطاء وقيود المستورد

مرحباً، هذا الموضوع يقدم بعض الخلفية حول الترحيل الذي أخطط له وأختبره ببطء. لقد جربت أخيرًا مستورد Drupal يوم الجمعة الماضي على بيئة اختبار VPS باستخدام مزيج من هذا و هذا. لا يزال المستورد قيد التشغيل أثناء كتابة هذا، لذلك لم أتمكن من اختبار وظائف موقع الاختبار فعليًا بعد، ولكنه على وشك الانتهاء قريبًا.

أكبر مشكلة أواجهها هي “قيمة مفتاح مكررة” في 8 عقد عشوائية على ما يبدو (ما يعادل المواضيع في 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 لا يتضمن معرف العقدة التي يرتبط بها التعليق، بينما يتضمن 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 core، لذلك لا يقوم البرنامج النصي بأي شيء لاستيرادها؛ إنه يعمل فقط. يمكنك البحث في البرامج النصية الأخرى عن “avatar” للعثور على أمثلة حول كيفية القيام بذلك.

ابحث عن أمثلة. . . . يحتوي ipboard على import_private_messages.

شكراً على الرد. لا أعتقد أن هذه مشكلة في قاعدة بيانات دروبال، لأنني فحصت قاعدة البيانات المصدر ولا يمكنني العثور على مفاتيح nid مكررة.

آه، لذا لديه هذه الوظيفة خارج drupal.rb. الآن بعد أن قمت بفحص موقع الاستيراد التجريبي، يبدو في الواقع أنه تعامل مع تحويلات أسماء المستخدمين بشكل جيد جدًا. شكرًا!

ما هي أسهل طريقة لتمكين استيراد أسماء المستخدمين التي تستخدم ترميز يونيكود (Unicode) (دون تحويلها، أي الاحتفاظ باسم المستخدم Narizón بدلاً من تحويله إلى Narizon

لقد أجريت اختباري الأول لوحدة استيراد Drupal على نسخة لم يتم فيها تكوين واجهة مستخدم رسومية للويب، لذلك لم أقم بتعيين خيار Discourse للسماح بأسماء مستخدمين تستخدم ترميز يونيكود. إذا تم تعيين ذلك، فهل كانت وحدة الاستيراد ستحترمه؟ ما هي الطريقة الموصى بها لتمكين ذلك عند إجراء ترحيل الإنتاج الخاص بي؟

وفي هذه الأثناء، بالنسبة لنسخة الاختبار الحالية الخاصة بي، هل هناك أي أمر rake لتطبيق الاسم الكامل (fullname) على اسم المستخدم؟ (لقد قمت بالفعل بتفعيل prioritize username in ux ولكن نظرًا لأن مستخدمي الاختبار لدي معتادون على Drupal الذي يدعم أسماء المستخدمين فقط لتسجيل الدخول [وليس عنوان البريد الإلكتروني]، أعتقد أنه سيكون من الأفضل الحفاظ على أسماء المستخدمين الخاصة بالإنتاج لديهم، والتي تم الاحتفاظ بها على الأقل في حقل الاسم الكامل.)

ربما؟

يمكنك تعيين إعداد الموقع في بداية البرنامج النصي.

أعتقد أن تغيير أسماء المستخدمين فكرة سيئة، ولكن إذا كنت لا تحبها، يمكنك تغيير ما يتم تمريره إلى مولد اسم المستخدم.

شكرًا، هل تقصد تغييرها بعد اكتمال الاستيراد؟

أعتقد أنني أقصد تغييرها على الإطلاق إلا إذا كانت أسماء المستخدمين غير مرئية في النظام القديم ورأوا الأسماء الحقيقية فقط

إذا كان الأمر الأخير هو الحال، فسأقوم بتغيير البرنامج النصي لجعل اسم المستخدم هو اسمه الحقيقي. المشكلة في ذلك هي أنه إذا لم يعرفوا عنوان بريدهم الإلكتروني فلن يتمكنوا من العثور على حساباتهم.

Gotcha. في منتدى 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

بالتأكيد، لقد كنت على حق. إنه ليس خطأ بحد ذاته، بل تبين أنه طريقة غريبة تتعامل بها دروبال مع المواضيع المنقولة مع ترك فتات خبز في فئة الموضوع السابق. إنه ببساطة ينشئ صفًا مكررًا في أحد الجداول العديدة التي يتم سحبها جميعًا لتصبح في النهاية موضوع دروبال كامل. لذلك يبدو أنني بحاجة إلى معرفة كيفية تطبيق DISTINCT على جدول واحد فقط من الجداول التي يتم تحديدها…

نعم. من المدهش كيف أن كل استيراد هو ندفة ثلج، وبطريقة ما فإن منتداك هو الأول الذي واجه هذه المشكلة (بالطبع، ربما يكون الكثير من الأشخاص قد حلوا المشكلة ولم يتمكنوا من تقديم طلب سحب مع التحديث). أو ربما تجاهلوا الأخطاء؟

آها. أظن أنها ليست وظيفة شائعة الاستخدام، عندما يتم نقل موضوع إلى فئة جديدة، هناك مربع اختيار اختياري لترك رابط “تم النقل إلى…” في الفئة القديمة.
النسخة المكررة المسيئة موجودة في عمود 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 (معرف الفئة). هذا يتبع الأسلوب الذي تعلمته خلال هذه المحنة مع قاعدة بيانات Drupal. لا أعرف شيئًا عن تصميم قواعد البيانات، لكن لدي انطباع بأنه يمكنك إما تخزين البيانات بشكل صريح، أو ترك بعض الأشياء ضمنية ثم اكتشافها عبر المنطق البرمجي؛ يبدو أن Drupal يقع بشكل مباشر في المعسكر الأخير.

حسنًا، يبدو أنني كدت أصل إلى الهدف. شكرًا جزيلاً لـ Jay على التوجيه.

شكرًا لك، كانت هذه هي النقطة المحورية. في الواقع، كان الأمر بسيطًا لدرجة نسخ جزء الرابط الدائم من سكريبت استيراد 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 الافتراضي قد تكون كافية. تجدر الإشارة إلى أنه يجب تعديلها لكل منتدى، فهناك استبدال تعبير منتظم (regexp) ثابت بشكل غير دقيق لـ site.comcommunity.site.com. ولكن بعد تعديل ذلك، تقوم الدالة بعمل جيد في إعادة كتابة روابط المنتدى الداخلية من العقد (nodes) → مواضيع، وكذلك من التعليقات → ردود. لكن لدي عدد كبير من المواقع الخارجية التي ترتبط بتعليقات فردية (ردود) على منتداي، ومن الجدير الحفاظ على هذه الروابط. بالإضافة إلى ذلك، يقوم Google بفهرسة معظم عناوين URL البالغ عددها 1.7 مليون /comment-YYYYYY، وقد يضر ذلك بترتيب موقعي إذا اختفت جميعها. آمل ألا يسبب ذلك أي مشاكل لـ Discourse بوجود حوالي 2 مليون رابط دائم!


شكرًا جزيلاً، قمت بنسخ هذه الدالة تقريبًا دون تعديلات، كان علي فقط تعديل أسماء بعض الأعمدة. تعمل بشكل رائع.

  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 "تخطي pm:#{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
# ابحث عن معرف أول رسالة خاصة لسلسلة الرسائل الخاصة
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:\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.


إذن، الكود ليس أنيقًا على الإطلاق وربما ليس فعالًا جدًا، لكنه بالنسبة لمهمة لمرة واحدة يجب أن ينجز المهمة.

آسف على جدران الكود، أخبرني إذا كان ذلك يزعج أي شخص ويمكنني نقلها إلى رابط Pastebin.

وهكذا تسير معظم الأمور. هذا الموضوع مثال رائع لشخص آخر ليتبعه (بالنظر إلى أنه يبدأ بمجموعة مهارات مماثلة لمهاراتك).

هل لديك تقدير للمدة التي قضيتها في إجراء تخصيصاتك؟

مبروك!

شكراً جاي! أقدر التشجيع.

أوه، أفضل ألا أفكر في ذلك. :stuck_out_tongue_winking_eye: ربما استغرق الأمر أكثر من 15 أو 20 ساعة بعد أن وضعتني على المسار الصحيح باستعلام SQL.

أود أن أستفيد من خبرتك في هذا الأمر إذا كانت لديك أي أفكار:

استغرق الأمر حوالي 70 ساعة لإجراء تجربة كاملة ببيانات الإنتاج على خادم افتراضي خاص قوي جدًا. أود أن أجعل مستخدمي يتفاعلون مرة أخرى في أسرع وقت ممكن حتى لو كان استيراد المنشورات والرسائل الخاصة لا يزال غير مكتمل. أو فكرة بديلة أخرى خطرت ببالي وهي تعطيل وظيفة preprocess_posts، والتي قمت بتعديلها أيضًا بشكل كبير مع استبدالات إضافية لـ gsub للتعبيرات العادية، وكذلك لتمرير جميع المنشورات والرسائل الخاصة عبر Pandoc بأحد أوامرين مختلفين اعتمادًا على ما إذا كان المنشور الأصلي عبارة عن علامات Textile أو HTML خالصة. إذا قمت بتعطيل روتين preprocess_posts بالكامل، فقد يقلل ذلك من وقت الاستيراد إلى النصف تقريبًا، وبعد ذلك يمكنني إضافة كل تلك الأشياء المتعلقة بالتنسيق إلى قسم postprocess_posts بمجرد استيراد جميع البيانات الأولية. ولكن العيب هو أنه بعد ذلك لن أتمكن من الوصول بسهولة إلى عمود قاعدة البيانات الأصلي الذي يظهر تنسيق المصدر (Textile أو HTML) لكل منشور، وهو شرط لمعالجة Pandoc الخاصة بي. أو هل يمكنني إضافة حقل مخصص لكل منشور يصفه بأنه textile أو html ثم استعادته لاحقًا أثناء المعالجة اللاحقة؟ لا أعرف، أفكر بصوت عالٍ فقط.

عند تشغيل البرنامج النصي للاستيراد مرة أخرى مع البيانات الجديدة فقط، فسيعمل بشكل أسرع بكثير نظرًا لأنه لن يقوم باستيراد البيانات مرة أخرى. لذلك سيستغرق بضع ساعات فقط. وكل تشغيل لاحق سيكون أسرع نظرًا لقلة البيانات التي سيتم استيرادها.
يمكنك بعد ذلك تسريع ذلك عن طريق تعديل الاستعلامات لإرجاع البيانات الأحدث فقط من وقت معين. تحتوي معظم البرامج النصية التي تعاملت معها على إعداد import_after لهذا الغرض (ولكن أيضًا للسماح بتطوير أسرع عن طريق استيراد مجموعة فرعية صغيرة من البيانات).