كتابة مليار أغنية باستخدام C # و Deep Learning

سأشرح في هذه المقالة كيفية إنشاء موقع ويب ASP.NET Core ، يستخدم AI لإنشاء كلمات أغنية فريدة بنقرة زر واحدة ، ويتيح للمستخدمين التصويت لأفضل الأغاني.

الشبكة العصبية


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

نموذج نصي GPT-2


لم يطلقوا أكبر شبكة عصبية بأكثر من مليار معلمة قاموا ببنائها اعتبارًا من اليوم (قرار مثير للجدل للغاية) ، لكنهم أطلقوا نسخة مفتوحة من معلمات 117M أصغر على GitHub بموجب ترخيص MIT. النموذج له اسم لا يُنسى: GPT-2 .


منذ حوالي شهر ، عندما كنت أحاول التفكير في المشروع الرائع الذي يمكنني القيام به مع TensorFlow ، أصبحت تلك الشبكة هي نقطة البداية. إذا كان من الممكن بالفعل إنشاء نص باللغة الإنجليزية ، فلا ينبغي أن يكون من الصعب ضبطه لإنشاء كلمات الأغاني ، إذا كان هناك مجموعة بيانات كبيرة بما فيه الكفاية.


كيف تعمل GPT-2؟


هناك العديد من الإنجازات الهامة في مجال البحوث التعليمية العميقة ، والتي جعلت GPT-2 ممكنة:


التعلم بالإشراف الذاتي


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


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


RECAPTCHA: العثور على علامات stree


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


لنأخذ النص كمثال. تأخذ GPT-2 عينة من النص الأصلي ، وتختار 15٪ من الرموز المميزة للتلف ، ثم تقنع 80٪ منها (على سبيل المثال يستبدل الرمز المميز للقناع ، عادة ___) ، يستبدل 10٪ مع بعض الرموز الرمزية العشوائية الأخرى من القاموس ، ويحافظ على 10 ٪ المتبقية سليمة. خذ رميت كرة ، وسقطت على العشب . بعد الفساد ، قد يبدو الأمر كما يلي: رميت كرة سيارة ، وقلت إلى العشب . في المصطلحات العادية ، للحصول على استعادة الشبكة للشبكة الأصلية ، يجب أن تتعلم ، من المحتمل أن يسقط شيء ما ، وأن كرة السيارة شيء غير شائع في السياق.


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


الاهتمام الذاتي المتناثر


يستخدم GPT-2 ما يسمى الاهتمام الذاتي المتناثر. في جوهرها ، إنها تقنية تمكن الشبكة العصبية من معالجة مدخلات كبيرة للتركيز على بعض أجزاء منها أكثر من غيرها. وتتعلم الشبكة أين يجب أن "تنظر" أثناء التدريب. يتم شرح آلية الاهتمام بشكل أفضل في منشور المدونة هذا .


يشير الجزء المتناثر في عنوان هذا القسم إلى وجود قيود على أجزاء المدخلات التي يمكن لآلية الانتباه الاختيار من بينها. الاهتمام الأولي يمكن أن تختار من المدخلات بأكملها. تسبب ذلك في أن تكون مصفوفة الوزن O (input_size ^ 2) ، والتي تنمو بسرعة كبيرة مع حجم الإدخال. الانتباه المتناثر عادة ما يقيد ذلك بطريقة ما. لمزيد من المعلومات حول ذلك ، ألقِ نظرة على منشور مدونة OpenAI آخر .


الاهتمام في GPT-2 متعدد الرؤوس . تخيل أنه يمكن أن يكون لديك عين إضافية أو عينتان يمكنك استخدامها للتحقق مما كان في الفقرة الأخيرة دون التوقف عن قراءة الفقرة الحالية.


كثير أكثر


الاتصالات المتبقية ، ترميز زوج البايت ، توقع الجملة التالية ، وغيرها الكثير.


ترقية GPT-2 (وتحويل بايثون بشكل عام)


رمز النموذج الأصلي موجود في Python ، لكنني رجل C #. لحسن الحظ ، فإن شفرة المصدر قابلة للقراءة تمامًا ، وجوهرها في 5 ملفات فقط ، ربما 500 سطر. لذلك قمت بإنشاء مشروع .NET Standard جديد ، وقمت بتثبيت Gradient (ارتباط TensorFlow لـ .NET) ، وقمت بتحويل هذه الملفات سطراً إلى C #. استغرقني ذلك حوالي ساعتين. الشيء الوحيد الذي بقي في بيثون الكود هو استخدام Python regex module من pip (مدير الحزم الأكثر استخدامًا لبيثون) ، حيث أنني لم أرغب في تضييع الوقت في تعلم تعقيدات Python العادية ( كما لو لم يكن ذلك كافيًا) للتعامل مع .NET منها بالفعل ).


يتكون التحويل في الغالب من تحديد فئات متشابهة وإضافة أنواع وإعادة كتابة فهم قائمة بيثون في تصميمات LINQ المقابلة. بالإضافة إلى LINQ من المكتبة القياسية ، استعملت MoreLinq ، الذي يوسع قليلاً ما يمكن أن يفعله LINQ على سبيل المثال:


bs = list(range(ord("!"), ord("~")+1)) + list(range(ord("¡"), ord("¬")+1)) + list(range(ord(""), ord("ÿ")+1)) 

تحولت إلى:


 var bs = Range('!', '~' - '!' + 1) .Concat(Range('¡', '¬' -'¡' + 1)) .Concat(Range('', 'ÿ' - '' + 1)) .ToList(); 

شيء آخر كان علي أن أواجهه هو وجود تباين بين الطريقة التي تتعامل بها بيثون مع النطاقات وميزة النطاقات والمؤشرات الجديدة في C # 8 القادمة ، والتي اكتشفتها أثناء تصحيح أخطاء التشغيل الأولي الخاص بي: في نهاية المطاف ، كانت نهاية النطاق شاملة ، بينما في بيثون ، يكون حصريًا (لتضمين العنصر الأخير في بيثون عليك حذف الجانب الأيمن من .. التعبير).


هناك شيئان صعبان في علوم الكمبيوتر: إبطال ذاكرة التخزين المؤقت ، تسمية الأشياء ، والأخطاء الفاصلة.

لسوء الحظ ، لم يحتوي إسقاط المصدر الأصلي على أي تدريب أو حتى رمز ضبط ، لكن نيل شيبيرد قدم موالفًا بسيطًا على GitHub ، والذي اضطررت إلى نقله أيضًا. على أي حال ، فإن نتيجة هذا الجهد هي رمز C # ، والذي يمكن استخدامه للعب مع GPT-2 ، وهو الآن جزء من مستودع Gradient Samples .


الهدف من التمرين هو ثنائي: بعد النقل يمكن للمرء أن يلعب برمز النموذج في كتابه C # IDE المفضل ، ولإظهار أنه من الممكن الآن أن تعمل نماذج التعلم المتعمق الحديثة في العرف مشاريع .NET بعد فترة وجيزة من الإصدار (بين إسقاط الكود GPT-2 والإصدار الأول من Billion Songs - ما يزيد قليلاً عن شهر).


صقل أغنية كلمات


هناك عدة طرق يمكن للمرء الحصول على مجموعة كبيرة من كلمات الأغاني. يمكنك أن تتخلص من أحد مواقع الإنترنت التي تستضيفها باستخدام محلل HTML ، أو اسحبه خارجًا من مجموعة karaoke ، أو ملفات mp3. لحسن الحظ ، شخص ما فعل ذلك بالنسبة لنا. لقد وجدت عددًا لا بأس به من مجموعات بيانات كلمات الأغاني المعدة على Kaggle . يبدو أن " كل أغنية سمعتها " هي الأكبر. في محاولة لضبط GPT-2 لذلك ، واجهت مشكلتين.


قراءة CSV


نعم ، لقد قرأته بشكل صحيح ، وكان تحليل CSV يمثل مشكلة . في البداية ، أردت استخدام ML.NET ، مكتبة Microsoft الجديدة للتعلم الآلي ، لقراءة الملف. ومع ذلك ، بعد التنقيب في الوثائق وإعدادها ، أدركت أنها فشلت في معالجة فواصل الأسطر في الأغاني بشكل صحيح. بغض النظر عن ما فعلته ، فقد ناضلت بعد بضع مئات من الأمثلة ، وبدأت في مزج قطع من الكلمات مع العناوين والفنانين.


لذلك اضطررت إلى اللجوء إلى مكتبة ذات مستوى أدنى ، والتي سبق لي أن كانت تجربة أفضل مع: CsvHelper . ويوفر واجهة تشبه DataReader . يمكنك رؤية الكود الذي يستخدمه هنا . بشكل أساسي ، تقوم بفتح ملف ، وتكوين CsvReader ، ثم تشبيك استدعاء .Read () مع استدعاء (مكالمات) إلى. GetField (fieldName) .


الأغاني القصيرة


معظم الأغاني قصيرة مقارنة بمقال متوسط ​​في مجموعة البيانات الأصلية التي يستخدمها OpenAI. يعد تدريب GPT-2 أكثر فعالية على أجزاء كبيرة من النص ، لذلك اضطررت إلى تجميع العديد من الأغاني في مجموعات نصية مستمرة لإطعامهم للمدرب. يبدو أن OpenAI يستخدم هذه التقنية أيضًا ، لذلك كان لديهم رمز مميز <| endoftext |> ، والذي يعمل بمثابة فاصل بين النصوص الكاملة داخل قطعة ، ويتضاعف كعلامة بداية. قمت بتجميع الأغاني حتى يتم الوصول إلى عدد معين من الرموز ، ثم قمت بإعادة المجموعة بالكامل لتضمينها في بيانات التدريب. رمز ذات الصلة هنا .


متطلبات الأجهزة لضبط


حتى الإصدار الأصغر من GPT-2 كبير. مع 12 غيغابايت من ذاكرة الوصول العشوائي GPU ، يمكنني فقط ضبط حجم الدُفعة على 2 (على سبيل المثال ، تدريب على قطعتين في وقت واحد ، أحجام الدُفعات الأكبر تعمل على تحسين أداء GPU ونتائج التدريب). سوف 3 رمي من الذاكرة في CUDA. واستغرق الأمر نصف يوم لضبطه على الأداء المطلوب في جهاز V100 الخاص بي. المكافأة هي أنه يمكنك رؤية التقدم ، حيث يخرج رمز التدريب بين الحين والآخر بعض العينات التي تم إنشاؤها ، والتي تبدأ كنص بسيط ، وتبدو أكثر فأكثر كلمات الأغاني مع تقدم التدريب.


لم أحاول ذلك ، لكن التدريب على وحدة المعالجة المركزية سيكون بطيئًا جدًا .


نموذج مسبق الضبط


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


نموذج نصف المدربين لعب HAL9000 على لي
أقسم لك ، من المفترض أن أكتب لك
وأقسم لك ، أقسم
لقد دمرتها الآن ، أتمنى أن تصنعها
أتمنى أن تحلم ، أتمنى أن تحلم ، أتمنى أن تحلم ، أتمنى أن تحلم ، أتمنى أن تحلم به
حول
ما أنا ذاهب. أنا ذاهب. أنا ذاهب. أنا ذاهب ، ذاهب ، ذاهب ، ذاهب ، ذاهب ، ذاهب ،
انا ذاهب انا ذاهب ...

صنع موقع على شبكة الانترنت


OK! يبدو وكأنه أغنية (نوع من) ، والآن دعونا إنشاء موقع على شبكة الإنترنت!


نظرًا لأنني لا أخطط لتوفير أي واجهات برمجة تطبيقات ، اخترت قالب صفحات Razor بدلاً من MVC. لقد شغّلت التفويض أيضًا ، حيث سنسمح للمستخدمين بالتصويت للحصول على أفضل كلمات والحصول على أفضل 10 مخطط.


لقد اندفعت MVP ، ومضتُ في إنشاء صفحة ويب لـ Song.cshtml ، والتي سيكون هدفها في الوقت الحالي هو الاتصال بـ GPT-2 والحصول على أغنية عشوائية. تخطيط الصفحة تافه ، ويتألف بشكل أساسي من الأغنية وعنوانها:


 @page "/song/{id}" @model BillionSongs.Pages.SongModel</p> @{ ViewData["Title"] = @Model.Song.Title ?? "Untitled"; } <article style="text-align: center"> <h3>@(Model.Song.Title ?? "Untitled")</h3> <pre>@Model.Song.Lyrics</pre> </article> 


الآن ، لأنني أعجبتني في إعادة استخدام الشفرة ، فقد أنشأت واجهة تسمح لي بتوصيل مولدات كلمات مختلفة لاحقًا ، والتي سيتم حقنها بواسطة ASP.NET في SongModel.


 interface ILyricsGenerator { Task<string> GenerateLyrics(uint song, CancellationToken cancellation); } 

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


كلمات تكرار الجيل


نظرًا لأنني أدليت ببيان جريء في العنوان ، فستكون أكثر من مليار أغنية ، ولا تفكر في توليدها وتخزينها جميعًا. أولاً ، بدون أي بيانات وصفية ، سيستغرق ذلك مساحة تفوق 1 تيرابايت من مساحة القرص. ثانياً ، يستغرق الأمر 3 دقائق تقريبًا على Nettop لإنشاء أغنية جديدة ، لذلك سوف يستغرق الأمر جميعًا إلى الأبد لإنشاء كل هذه الأغاني. وأريد أن أكون قادرًا على تحويل هذا المليار إلى خمسة ملايين من خلال التحول إلى Int64 إذا لزم الأمر! تخيل أننا يمكن أن نصنع 1 سنت لكل أغنية ، على أغنية واحدة من خمسة ملايين؟ سيكون ذلك أكثر من الناتج المحلي الإجمالي السنوي الحالي في العالم!


بدلاً من ذلك ، ما يتعين علينا القيام به هو التأكد من أن GPT-2 تولد نفس الأغنية مرارًا وتكرارًا ، بالنظر إلى هويتها التي حددتها في المسار. لهذا الغرض ، يمنح TensorFlow القدرة على تعيين نواة مولد الأرقام الداخلية في أي وقت عبر وظيفة tf.set_random_seed مثل هذا: tf.set_random_seed (songNumber) . ثم أردت فقط الاتصال بـ Gpt2Sampler.SampleSequence ، للحصول على نص الأغنية المشفرة ، وفك تشفيرها ، وإرجاع النتيجة ، وبالتالي إكمال Gpt2LyricsGenerator .


لسوء الحظ ، عند المحاولة الأولى التي لم تنجح كما هو متوقع. في كل مرة كنت اضغط على زر التحديث ، سيتم إرجاع نص فريد جديد على الصفحة. بعد قليل من تصحيح الأخطاء ، اكتشفت أخيرًا ، أن TensorFlow 1.X لديه مشاكل كبيرة مع إمكانية التكرار: العديد من العمليات لها حالات داخلية ، لا تتأثر set_random_seed ويصعب الوصول إليها لإعادة ضبطها.


ساعد إعادة تهيئة متغيرات النموذج في تعويض هذه المشكلة ، ولكنه يعني أيضًا أنه يجب إعادة إنشاء الجلسة ، ويجب إعادة تحميل أوزان النموذج على كل مكالمة. إعادة تحميل جلسة بهذا الحجم تسبب تسرب ذاكرة عملاقة. لتجنب البحث عن السبب في التعليمات البرمجية المصدر TensorFlow C ++ ، بدلاً من القيام بعملية إنشاء النص ، قررت إنشاء عملية جديدة باستخدام Process.Start ، وإنشاء نص هناك ، وقراءته من الإخراج القياسي. إلى أن يتم تثبيت طريقة لإعادة ضبط حالة النموذج في TensorFlow ، ستكون هذه هي الطريقة المناسبة.


انتهى بي الأمر بفصلين : Gpt2LyricsGenerator ، الذي ينفذ ILyricsGenerator من الأعلى عن طريق وضع نسخة جديدة من BillionSongs.exe مع معلمات سطر الأوامر ، والتي تتضمن معرف الأغنية ، والتي تتضمن في نهاية المطاف Gpt2TextGenerator ، والتي تستدعي بالفعل GPT-2 لإنشاء كلمات ، و ببساطة يطبع بها.


الآن تحديث الصفحة أعطاني دائما نفس النص.


التعامل مع 3 دقائق من الوقت لتوليد أغنية


ما هي تجربة المستخدم الرهيبة سيكون! تذهب إلى موقع ويب ، وانقر على "إنشاء أغنية جديدة" ، ولا يحدث أي شيء على الإطلاق لمدة 3 دقائق (!) بينما يستغرق nettop وقتًا لإنشاء كلمات الأغاني التي طلبتها.


لقد حللت هذه المشكلة على مستويات متعددة:


الأغاني قبل التجديد


كما ذكرنا أعلاه ، لا يمكنك إنشاء جميع الأغاني مسبقًا ، وتقديمها من قاعدة بيانات. ولا يمكنك توليد الطلب فقط ، لأن ذلك يتباطأ. إذن ماذا يمكنك أن تفعل؟


! بسيطة نظرًا لأن الطريقة الأساسية للمستخدمين لرؤية أغنية جديدة هي النقر فوق الزر "Make Random" ، فلننشئ مسبقًا العديد من الأغاني مقدمًا ، ونضعها في قائمة ConcurrentQue ، ودع أغاني البوب ​​"Make Random" منه. على الرغم من انخفاض عدد الزوار ، سيستغرق الخادم بعض الوقت بينهما لتوليد بعض الأغاني ، والتي سيكون من السهل الوصول إليها بعد ذلك.


هناك خدعة أخرى ، استخدمتها وهي تنفيذ قائمة الانتظار عدة مرات ، حتى يتمكن العديد من المستخدمين من رؤية نفس الأغنية التي تم إنشاؤها مسبقًا. يحتاج المرء فقط للحفاظ على التوازن بين استخدام ذاكرة الوصول العشوائي ، وعدد المرات التي ينقر فيها المستخدم على "جعل عشوائي" لرؤية شيء رآه من قبل. لقد اخترت ببساطة 50000 أغنية كرقم معقول ، والذي سيستغرق 50 ميغابايت فقط من ذاكرة الوصول العشوائي ، مع توفير عدد كبير جدًا من النقرات.


لقد طبقت هذه الوظيفة في الفصل PregeneratedSongProvider : IRandomSongProvider (يتم حقن الواجهة في الكود ، المسؤول عن معالجة زر "Make Random").


التخزين المؤقت


يتم تخزين الأغاني التي تم إنشاؤها مسبقًا على ذاكرة التخزين المؤقت ، لكنني أيضًا قمت بتعيين رأس ذاكرة التخزين المؤقت لـ HTTP على العامة للسماح للمتصفح ، و CDN (أستخدم CloudFlare) بتخزينه مؤقتًا لتجنب التعرض لتدفق المستخدم.


 [ResponseCache(VaryByHeader = "User-Agent", Duration = 3*60*60)] public class SongModel: PageModel { … } 

إرجاع الأغاني الشعبية


معظم الأغاني التي تم إنشاؤها بواسطة GPT-2 تم ​​ضبطها بهذه الطريقة باهتة إلى حد ما ، إن لم تكن بدائية. لجعل النقرات على "Make Random" أكثر جاذبية ، أضفت احتمالًا بنسبة 25٪ ، أنه بدلاً من أغنية عشوائية تمامًا ستحصل على بعض الأغاني ، التي سبق أن قام مستخدمون آخرون بتفويتها. بالإضافة إلى زيادة المشاركة فإنه يزيد من فرصة طلب أغنية أو تخزينها مؤقتًا في CDN أو في الذاكرة.


جميع الحيل أعلاه موصولة سويًا باستخدام حقن تبعية ASP.NET في فئة بدء التشغيل .


تصويت


ليس هناك الكثير خاص حول تنفيذ التصويت. هناك SongVoteCache ، التي تبقي التهم محدثة. و iframe يستضيف زر التصويت على صفحة الأغنية ، والذي يسمح للجزء الأساسي من الصفحة - عنوان وكلمات التخزين المؤقت ، بينما يتم تحميل عدد الأصوات وحالة تسجيل الدخول في وقت لاحق.


النتائج النهائية


ولدت أغنية عينة


إصدار تجريبي يعمل على برنامج Nettop الخاص بي ، والذي تقدمه CloudFlare (أعطه بعض الركود ، Core i3) تم تجميده الآن وانتقل إلى طبقة خدمة Azure App Service المجانية.


مستودع جيثب ، يحتوي على شفرة المصدر ، وتعليمات لتشغيل موقع الويب وضبط النموذج.


خطط للمستقبل / التدريبات


توليد الألقاب


من السهل جدًا ضبط GPT-2. يمكن للمرء أن يجعلها تولد عناوين أغاني عن طريق الدفع المسبق أو لاحقة كل عينة من كلمات الأغاني من مجموعة البيانات برمز اصطناعي مثل <| startoftitle |> ، متبوعًا بالعنوان من نفس مجموعة البيانات.


بدلاً من ذلك ، يمكن السماح للمستخدمين باقتراح و / أو التصويت على العناوين.


توليد الموسيقى


في منتصف الطريق من خلال تطوير Billion Songs ، اعتقدت أنه سيكون من الرائع تنزيل مجموعة من ملفات MIDI (هذا تنسيق موسيقى بالمدرسة القديمة ، وهو أقرب بكثير إلى النص ، من ملفات mp3) ، وتدريب GPT-2 عليها لإنشاء المزيد . تحتوي بعض هذه الملفات على نص مضمن ، لذا يمكنك الحصول على إنشاء karaoke .


وأنا أعلم أن الجيل الموسيقي بهذه الطريقة أمر ممكن للغاية ، لأن تطبيق OpenAI بالأمس بدأ بالفعل تطبيقًا لهذه الفكرة في مدونته . ولكن ، الصيحة ، لم يفعلوا الكاريوكي! لقد وجدت أنه من الممكن أن تتخلص من http://www.midi-karaoke.info لهذا الغرض.


التدرج الملقب TensorFlow ل. NET


من فضلك ، انظر بلوق لدينا عن أي تحديثات.

Source: https://habr.com/ru/post/ar453232/


All Articles