Код
#статьи

Как автоматизировать работу продюсера с помощью JavaScript и Telegram-бота

Создаём веб-приложение для быстрого заполнения расписания новостной передачи.

Иллюстрация: Катя Павловская для Skillbox Media

Готовить эфиры бывает утомительно и непросто, поэтому, помимо ведущих, за кадром должна быть сильная команда помощников. И неважно, идёт ли речь о передаче на телевидении или на YouTube, Rutube, в VK и так далее: главные герои тыла — продюсеры, которые ищут экспертов, договариваются с гостями, предлагают темы для обсуждения, согласовывают всё это и составляют для всех расписания.

Миллиарды нервных клеток продюсеров стримов сгорели в попытках находить интересных героев, делать выпуски непохожими друг на друга, возвращать на связь внезапно исчезнувших с радаров гостей и, конечно, вписываться в график. В общем, продюсер эфира — этакий рокетмен, сжигающий свои предохранители. Но как тут может помочь программирование? Давайте попробуем разобраться.

Что здесь автоматизировать

Опыт участия в подготовке новостных эфиров, а также обсуждения темы с друзьями из других медиа заставили задуматься: а вдруг в работе продюсера, помимо творчества, есть шаблонные действия? Ведь, если это правда, часть задач можно поручить компьютеру! А это снижение нагрузки, освобождение части рабочего времени и прочее.

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

Дата эфира

При ежедневных выходах выбор как минимум между сегодня и завтра. Повестка быстро меняется, и планировать дальше может быть сложно — хотя когда как.

Время эфира

Допустим, утро или вечер — для разной ЦА. Кто-то внимательно посмотрит прямую трансляцию, кто-то хочет послушать эфир за рулём, кто-то на кухне за готовкой, некоторые подписчики включат его фоном во время работы, а остальные посмотрят запись на досуге.

Ведущие

Обычно это один или несколько человек из списка постоянных ведущих. Кто-то из них может быть в этот день недоступен по причине занятости, командировки, отпуска, болезни.

Темы, гости и время подключения

Это самый интересный и творческий пункт. Задача в том, чтобы определить самые актуальные темы и найти гостей (экспертов), которые впишутся в передачу и будут согласны ненадолго выйти в эфир с комментарием.

Но это ещё не всё. Хороших экспертов нужно взять на заметку и приглашать снова, а плохих — отсеять.

Согласование

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

Таким образом, есть несколько плавающих переменных, которые способны измениться в очень короткий срок. Но всё же это одни и те же переменные, и сбор информации действительно можно немножечко автоматизировать. Это мы и сделаем.

Алгоритм автоматизации

Наша программа будет простой и наглядной. Мы создадим локальную веб-страницу с самым необходимым:

  • приятным интерфейсом;
  • возможностью выбора опций эфира;
  • небольшой базой данных экспертов с возможностью добавления новых;
  • автоматическим заполнением текста расписания;
  • отправкой оформленного поста в рабочий Telegram-чат команды стрима.

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

Какие технологии будем использовать:

  • HTML и CSS для красивого дизайна;
  • язык JavaScript для программирования интерфейса;
  • API IndexedDB для создания локальной базы данных прямо в браузере (современными браузерами оно поддерживается);
  • Telegram Bot API для отправки расписания.

Готовый код мы разместили на pastebin.com:

Как будет выглядеть наше приложение

Допустим, мы сделаем приложение для продюсеров условной YouTube-передачи Skillbox FM с реальными ведущими и гостями (по мотивам уже вышедших эпизодов подкаста «Люди и код»).

Приложение для автоматизации подготовки эфиров — общий вид
Скриншот: Skillbox Media

Приложение будет следовать логике продюсера новостного эфира: определять дату, время и ведущих. А после этого сопоставлять экспертов и время выхода. В конце приложение покажет расписание всем ведущим.

Взглянем на всё это как программисты:

  • по сути, нам надо сделать набор чекбоксов, которые администратор проставляет в нужном порядке;
  • выбор времени подключения привязан к блоку «Вид эфира» и устанавливается, когда отмечаем утро или вечер;
  • для добавления экспертов в расписание к конкретному времени нужно по очереди щёлкнуть на время подключения, а затем на имя гостя — и они встанут на свои места;
  • все опции сконцентрированы вокруг требуемого результата (текста поста). Поэтому блок «Предпросмотр поста» должен располагаться по центру.

Вот как отреагирует программа, если мы выберем случайные опции.

Нажав на голубую кнопку вверху, мы отправим в Telegram-чат вот такой пост.

Теперь разберём, как написать такую программу.

Реализация

Код будет упакован в два файла: Air Constructor.html и speakersDB.js. Первый — сама страница (HTML, CSS и немного JavaScript). Второй — всё, что связано с базой данных экспертов (JavaScript-код, который мы подключим к веб-странице).

Посмотрим на применение указанных инструментов.

Вёрстка интерфейса — HTML и CSS

Наш интерфейс должен быть не только приятным, но и привычным для продюсера, поэтому его нужно оформить в фирменном стиле медиа. В нашем случае ведущий цвет — синий (код #3D3BFF).

С вёрсткой мудрить не будем:

  • элементарная сетка из трёх <div>-блоков для разделения страницы на три вертикальных уровня (<div id="firstBlock">, <div id="secondBlock">, <div id="thirdBlock">);
  • первый уровень (см. скриншот) — заголовок «Расписание эфира» и кнопка отправки на синем фоне;
  • второй уровень — блоки «Дата эфира», «Вид эфира», «Ведущие»;
  • третий уровень — блоки «Время подключения», «Предпросмотр поста», «Спикеры»;
  • CSS-правил будет немного, поэтому разместим их не в отдельном файле, а внутри страницы с помощью элемента <style>.

С опциями интерфейса в целом всё тоже просто: чтобы дать пользователю возможность выбора пунктов, вставим в наши <div>-блоки шесть HTML-форм (элементы <form>), внутри которых будут связанные элементы <input>/<label>, <fieldset>/<legend> или <textarea>.

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

  • форма (элемент <form>) — это секция документа, содержащая интерактивные элементы для отправки информации. Содержит элементы формы (см. ниже);
  • инпут (элемент <input>) — это как раз элемент управления одного из нескольких возможных типов. Мы будем использовать инпуты типа radio (выбор только одного варианта) и checkbox (выбор любого количества вариантов);
  • элемент <label> — это подпись для связанного инпута. Связь указывается с помощью атрибутов id и for;
  • элемент <fieldset> группирует несколько управляющих элементов формы, а связанный с ним <legend> буквально добавляет над ними «легенду» (красивый заголовок).

Мы нарушим эту схему только в двух случаях: для кнопки отправки в Telegram укажем тип submit («отправка», без подписи с помощью <label>), а форма предпросмотра поста будет содержать только текстовый блок <textarea> для расписания.

Давайте теперь настроим отображение этих элементов. Для начала сбросим дефолтные стили браузера, чтобы самостоятельно задать отступы и шрифт.

    	* {
   		 font-family: Intro Light, sans-serif;
   		 margin: 0;
   		 padding: 0;
   	 }

Далее сделаем ширину трёх главных блоков сетки равной ширине страницы (заодно установим и другие опции).

    	#firstBlock {
   		 width: 100%;
   		 position: relative;
   	 }
    	#secondBlock {
   		 width: 100%;
   		 margin-left: 5%;
   	 }
    	#thirdBlock {
   		 width: 100%;
   		 margin-left: 5%;
   		 clear: both;
   	 }

Позаботимся о стиле заголовка страницы:

    	h1 {
   		 background-color: #3D3BFF;
   		 color: #fff;
   		 width: 100%;
   		 font-family: Intro Black, sans-serif;
   		 text-align: center;
   		 padding: 5px;
   	 }

И стиле кнопки отправки в Telegram:

    	#sendButton {
   		 width: 170px;
   		 height: 30px;
   		 position: absolute;
   		 top: 10%;
   		 right: 4%;
   		 background-color: #179cde;
   		 border: 1px dashed white;
   		 color: #fff;
   		 font-family: Intro Black, sans-serif;
   		 cursor: pointer;
   	 }

А этот стиль поможет правильно выстроить блоки меню относительно друг друга — чтобы они не съезжали со строк и не наезжали друг на друга. Первые три блока — одинаковые.

    	#first_form, #second_form, #third_form {
   		 width: 30%;
   		 margin-top: 1%;
   		 margin-bottom: 1%;
   		 margin-right: 5px;
   		 float: left;
   	 }

Формы 4–6 должны быть разными по ширине: больше места под расписание и базу гостей и меньше — для времени подключения. Обратите внимание, что в коде фактически три разных варианта четвёртой формы (выбор времени подключения) — просто отображаться должен только один (об этом ниже).

    	#fourth_form_filler, #fourth_form_morning, #fourth_form_evening  {
   		 width: 20%;
   		 margin-bottom: 1%;
   		 margin-right: 5px;
   		 float: left;
   	 }
    	#fifth_form, #sixth_form {
   		 width: 35%;
   		 margin-bottom: 1%;
   		 margin-right: 5px;
   		 float: left;
   	 }

Отдельные настройки области для текста (элемент <textarea> в пятой форме). В частности, убираем возможность менять её размер (resize: none) и добавляем возможность прокрутки на случай, если текста будет много (ну мало ли).

    	#fifth_form > fieldset {
   		 overflow: scroll;
   	 }
    	textarea {
   		 width: 95%;
   		 height: 92%;
   		 padding: 2%;
   		 font-size: 18px;
   		 resize: none;
   		 border: 0;
   	 }

Оставшиеся CSS-правила не так важны — и вы сможете увидеть их в финальном варианте. А мы пойдём к самому интересному — программированию поведения элементов.

Пока ещё не касаясь базы данных, отметим менее очевидные задачи: необходимо сбросить дефолтный выбор пунктов меню и вовремя добавить функции — обработчики событий на клики по различным пунктам.

Этот блок мы добавим ближе к началу кода HTML-страницы — в элемент <head>:

    	<script>
   		 // Функция отправки расписания в Telegram-чат (привязывается к кнопке отправки).
   		 function sendSchedule() {
   			 // Конструктор ссылки для отправки новостей в Telegram: токен бота, ID чата, способ кодировки, заголовок + текст дайджеста, предупреждение.
   			 let token = '12345abcd';
   			 let chat = '-10012345';
   			 let text = encodeURIComponent(document.getElementsByTagName('textarea')[0].value);
   			 let sendURL = 'https://api.telegram.org/bot' + token + '/sendMessage?chat_id=' + chat + '&parse_mode=HTML&text=' + text;
   			 fetch(sendURL);
   			 alert('Расписание отправлено.');
   			 console.log('Отправка расписания в Telegram-чат.');
   		 };
   		 
   		 // Функция отмены предварительного выбора пунктов в меню.
   			 function uncheckInputs() {
   				 var inputs = document.getElementsByTagName('input');
   				 for (var i = 0; i < inputs.length; i++) {
   					 inputs[i].checked = false;
   				 };
   			 };
   	 </script>

Остановимся ненадолго на реализации отправки поста в Telegram (функция sendSchedule). Нужно всего лишь сделать GET-запрос методом sendMessage() из Telegram Bot API с помощью JavaScript-метода fetch(). Требуется только подставить в нужные переменные токен вашего бота (его можно получить при создании бота у BotFather) и ID чата.

Далее конструктор собирает из переменных ссылку для запроса, а чтобы функция срабатывала по клику на кнопку отправки, мы добавляем кнопке обработчик с названием функции:

<input type="submit" value="Отправить расписание" id="sendButton" onclick="sendSchedule()">

Ещё один скрипт — после отрисовки первой формы «Дата эфира», для которой требуется получить сегодняшнюю и завтрашнюю даты.

   	 	<script>   	 
   			 // Определяем и вставляем актуальные даты (сегодня и завтра).
   			 let now = new Date();
   			 let now2 = new Date();
   			 now2.setDate(now2.getDate() + 1);
   			 let month = [
   				 'января',
   				 'февраля',
   				 'марта',
   				 'апреля',
   				 'мая',
   				 'июня',
   				 'июля',
   				 'августа',
   				 'сентября',
   				 'октября',
   				 'ноября',
   				 'декабря'
   			 ];
   				 let today = now.getDate().toString() + ' ' + month[now.getMonth()];
   				 let tomorrow = now2.getDate().toString() + ' ' + month[now2.getMonth()];
   			 document.getElementById('replace1').innerHTML = 'Сегодня, ' + today;
   			 document.getElementById('replace2').innerHTML = 'Завтра, ' + tomorrow;
   		 </script>

В самый конец тела страницы вставляем вызовы функции отмены предварительного выбора пунктов меню uncheckInputs() и функций — обработчиков кликов по пунктам меню. Приведём часть этого блока — остальное будет по тому же принципу.

   	 	// Если выбрано время "утро".
   		 function checkMorning() {
   			 if (document.getElementById('morning').checked = true) {
   				 document.getElementById('fourth_form_filler').setAttribute('style', 'display: none;');
   				 document.getElementById('fourth_form_evening').setAttribute('style', 'display: none;');
   				 document.getElementById('fourth_form_morning').setAttribute('style', 'display: inherit;');
   				 document.getElementsByTagName('textarea')[0].value += 'УТРО' + '\n\n' + 'Ведущие: ';
   			 };
   		 };
   		 // Если выбрано время "вечер".
   		 function checkEvening() {
   			 if (document.getElementById('evening').checked = true) {
   				 document.getElementById('fourth_form_filler').setAttribute('style', 'display: none;');
   				 document.getElementById('fourth_form_morning').setAttribute('style', 'display: none;');
   				 document.getElementById('fourth_form_evening').setAttribute('style', 'display: inherit;');
   				 document.getElementsByTagName('textarea')[0].value += 'ВЕЧЕР' + '\n\n' + 'Ведущие: ';
   			 };
   		 };
   		 document.getElementById('morning').addEventListener('click', checkMorning);
   		 document.getElementById('evening').addEventListener('click', checkEvening);

Теперь самое сложное — прикрутить базу данных на IndexedDB. Грубо говоря, мы создадим в браузере пользователя локальное хранилище.

Чтобы было удобнее, реализуем эту фичу отдельным модулем — и для начала в самый низ <body> вставляем обращение к файлу speakersDB.js:

<script src="speakersDB.js"></script>

Далее работаем в этом файле.

Вкратце алгоритм работы хранилища на IndexedDB выглядит следующим образом: открыть базу, создать или открыть хранилище объектов (object store) с ключом, совершать с ним транзакции.

Открываем базу данных

const dbName = 'База данных гостей эфира'; // Название базы данных.
let openRequest = indexedDB.open(dbName, 1); // Открытие базы данных.
    console.log('Открытие базы данных...');

Где «1» — это версия базы.

У попытки открытия может быть три возможных результата: либо базы ещё нет (и её нужно создать), либо ошибка, либо успех. Отсюда — три разных обработчика.

Если хранилище объектов ещё не создано:

	openRequest.onupgradeneeded = function() {
   	 let db = openRequest.result;
   	 if (!db.objectStoreNames.contains('Гости эфира')) { // Если хранилища 'Гости эфира' не существует...
   		 db.createObjectStore('Гости эфира', {keyPath: 'Name'}); // ...создаём хранилище.
   	 };
    };

Если у нас ошибка:

	openRequest.onerror = function() {
      console.error("Ошибка открытия базы данных.", openRequest.error);
    };

Самая длинная часть — если всё идёт по плану. База данных открыта — сначала ещё немного формальностей.

	openRequest.onsuccess = function() {
   	 let db = openRequest.result;
   	 console.log('База данных успешно открыта.');
   	 
   	 // Защита от повторного открытия вкладки.
   	 db.onversionchange = function() {
   		 db.close();
   		 alert("База данных устарела, пожалуйста, перезагрузите страницу.")
   	 };

Дальше нужно записать в базу гостей «по умолчанию», сделать возможность ручного добавления и вывести данные из базы на страницу в блок «Спикеры» (шестая форма).

Начнём с автоматического добавления стартового списка.

    	// Запись в базу данных стартовых значений.
   	 // Объявление массива с гостями по умолчанию.
   	 let initialGuests = [
   		 {
   			 Name: 'Никита Дубко',
   			 Post: 'Senior Frontend Developer, Google Developer Expert по Web',
   			 Telegram: '@dev_tip'
   		 },
   		 {
   			 Name: 'Светлана Вронская',
   			 Post: 'Эксперт департамента аналитических решений ГК «КОРУС Консалтинг»',
   			 Telegram: '@analyticsnow'
   		 },
   		 {
   			 Name: 'Евгений Некрасов',
   			 Post: 'DevOps-инженер кластеров и нейронных сетей',
   			 Telegram: '@ravino_doul_channel'
   		 },
   		 {
   			 Name: 'Роман Душкин',
   			 Post: 'Автор и ведущий просветительского YouTube-канала «Душкин объяснит»',
   			 Telegram: '@drv_official'
   		 }
   	 ];
   	 // Добавление гостей по умолчанию в базу, если их ещё там нет.
   	 for (let i = 0; i < initialGuests.length; i++) {
   		 let transactionWrite = db.transaction('Гости эфира', 'readwrite'); // Создание транзакции.
   		 let guests = transactionWrite.objectStore('Гости эфира'); // Получение хранилища объектов 'Гости эфира' для работы с ним.
   		 let addInitialGuest = guests.add(initialGuests[i]); // Добавление записи гостя в хранилище объектов.
   		 addInitialGuest.onsuccess = function() {
   			 console.log('Добавление в базу записи гостя по умолчанию: ' + initialGuests[i].Name);
   		 };
   		 addInitialGuest.onerror = function() {
   			 console.log('В базе найдены записи по умолчанию.');
   		 };
   	 };

Теперь позаботимся о возможности вручную добавить гостя в базу, чтобы продюсер мог ввести ФИО, место работы и Telegram-канал и одним кликом добавить данные в хранилище.

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

    	// Вывод формы добавления в базу нового гостя.
   		 // Объявление функции добавления нового гостя нажатием на кнопку.
   		 function addingGuest() {
   			 let transactionWrite2 = db.transaction('Гости эфира', 'readwrite'); // Создание транзакции.
   			 let guests2 = transactionWrite2.objectStore('Гости эфира'); // Получение хранилища объектов 'Гости эфира' для работы с ним.
   			 
   			 let getUserInputName = document.getElementById('newGuestName').value; // Получение введённого пользователем имени нового гостя.
   			 let getUserInputPost = document.getElementById('newGuestPost').value; // Получение введённых пользователем должности и места работы нового гостя.
   			 let getUserInputTelegram = document.getElementById('newGuestTelegram').value; // Получение введённой пользователем ссылки на Telegram-канал нового гостя.

   			 let newGuestFromUser = {
   				 Name: getUserInputName,
   				 Post: getUserInputPost,
   				 Telegram: getUserInputTelegram
   			 };
   			 
   			 // Добавление записи нового гостя в хранилище объектов.
   			 let addNewGuest = guests2.add(newGuestFromUser);
   			 addNewGuest.onsuccess = function() {
   				 console.log('Добавление в базу нового гостя: ' + newGuestFromUser.Name);
   			 };
   			 addNewGuest.onerror = function() {
   				 console.log('Ошибка добавления в базу нового гостя.');
   			 };
   		 };
   			 
   		 let newGuestNameInput = '<input type="text" id="newGuestName" name="newName" placeholder="Имя гостя" required minlength="5" size="30">';
   		 let newGuestPostInput = '<input type="text" id="newGuestPost" name="newName" placeholder="Место работы/должность гостя" required minlength="5" size="30">';
   		 let newGuestTelegramInput = '<input type="text" id="newGuestTelegram" name="newName" placeholder="Telegram-канал гостя (если есть)" minlength="5" size="30"><br>';
   		 let newGuestNameLabel = '<label for="newGuestName">Добавить гостя в базу:</label><br>';

   		 let newGuestButton = document.createElement('input');
   			 newGuestButton.setAttribute('type', 'button');
   			 newGuestButton.setAttribute('value', 'Добавить');
   			 newGuestButton.addEventListener('click', addingGuest);
   		 
   		 let path2 = document.getElementById('sixth_form').getElementsByTagName('fieldset')[0];
   			 path2.insertAdjacentHTML('beforeend', newGuestNameLabel);   			 
   			 path2.insertAdjacentHTML('beforeend', newGuestNameInput);
   			 path2.insertAdjacentHTML('beforeend', newGuestPostInput);
   			 path2.insertAdjacentHTML('beforeend', newGuestTelegramInput);
   			 path2.append(newGuestButton);
   			 path2.insertAdjacentHTML('beforeend', '<br><br>Гости в базе:<br>');

Наконец, уже существующих в базе спикеров нужно вывести на страницу и предложить для выбора. Это другая транзакция, и делается она следующим образом.

    	// Вывод на страницу гостей из базы данных.
   	 let transactionRead = db.transaction('Гости эфира', 'readonly'); // Создание транзакции.
   	 let existingGuests = transactionRead.objectStore('Гости эфира'); // Получение хранилища объектов 'Гости эфира' для работы с ним.
   	 let readRequest = existingGuests.getAll();
   	 readRequest.onsuccess = function(e) {
   		 console.log('ЧТЕНИЕ БАЗЫ ДАННЫХ');
   		 console.log('В базе найдено ' + readRequest.result.length + ' гостей.');
   		 
   		 // Перебор гостей в базе для уведомления в консоль и вывода на страницу.
   		 for (let eg = 0; eg < readRequest.result.length; eg++) {
   			 let printName = readRequest.result[eg].Name; // Получение ФИО.
   			 let printPost = readRequest.result[eg].Post; // Получение должности и места работы.
   			 let printTelegram = readRequest.result[eg].Telegram; // Получение ссылки на Telegram-канал.
   			 
   			 // Финальная строка с данными гостя (для вывода пользователю).
   			 let printGuest = printName + ', ' + printPost + ', ' + printTelegram;
   			 // Уведомление в консоль о найденном в базе пользователе.
   			 console.log('В базе найден гость ' + printGuest);
   			 
   			 // Вставка списка на страницу.
   			 let guestInput = document.createElement('input');
   				 guestInput.type = 'checkbox';
   				 guestInput.id = 'guest' + eg;
   				 guestInput.name = 'air_guest';
   			 let guestLabel = document.createElement('label');
   				 guestLabel.setAttribute('for', ['guest' + eg]);
   				 guestLabel.innerHTML = printGuest;
   			 // Добавление переноса строки после каждого гостя.
   			 let newLine = document.createElement('br');
   			 let path = document.getElementById('sixth_form').getElementsByTagName('fieldset')[0];
   				 path.append(guestInput);
   				 path.append(guestLabel);
   				 guestLabel.after(newLine);
   			 
   			 // Вставка функции выбора гостей к каждому пункту с именами гостей.
   			 for (g = 0; g < document.getElementsByName('air_guest').length; g++) {
   				 document.getElementsByName('air_guest')[g].addEventListener('click', checkGuest);
   			 };
   			 
   		 };
   	 };
};

На этом реализация закончена.

Убедиться в правильности работы хранилища можно двумя способами.

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

Консольные уведомления о работе локального хранилища на IndexedDB (вид в Mozilla Firefox)
Скриншот: Skillbox Media

Во-вторых, на него можно взглянуть в браузерных инструментах разработчика на вкладке Приложение → Хранилище → IndexedDB (в Google Chrome) или Хранилище → IndexedDB (в Mozilla Firefox).

Заключение

Наш эксперимент увенчался успехом, но это лишь первая бета-версия с самыми важными функциями. И к ней всегда можно добавить что-то ещё. Однако важнее другое: мы не использовали никаких фреймворков и сложных «продвинутых» языков. То есть для такого приложения достаточно относительно простых и известных каждому веб-разработчику инструментов (если не считать IndexedDB). А экономия времени получается колоссальная.

Проверьте свой английский. Бесплатно ➞
Нескучные задания: small talk, поиск выдуманных слов — и не только. Подробный фидбэк от преподавателя + персональный план по повышению уровня.
Пройти тест
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована