Как стать автором
Обновить
1910.04
Timeweb Cloud
То самое облако

Нейросеть мне в помощь или как я сделал телеграм бота, который умеет переводить песни

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров11K

Однажды, когда я искал эффективное решение для преобразования речи в текст (транскрибации), чтобы применить его в своем проекте умной колонки, обнаружил интересное решение под названием Whisper от широко известной компании Open AI. К сожалению, Whisper не подошел для реализации в моем проекте по «аппаратным» причинам, но его функционал отпечатался в моей душе. Прошло время и меня посетила идея: «Почему бы не разработать телеграмм бота, куда бы пользователь мог отправлять аудиофайл, а в ответ получал текстовую расшифровку и перевод (песни) на родной язык». В этой статье я расскажу о реализации данной идеи и Whisper в этом проекте займет одну из ключевых функций.

Чем же меня так впечатлил Whisper?


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

Ну и само название модели «Whisper» переводится как шепот, что само по себе намекает о качестве распознавания речи.

Чтобы не быть голословным, давайте запустим базовый пример использования и посмотрим вывод:

import whisper
model = whisper.load_model("base")

# load audio and pad/trim it to fit 30 seconds
audio = whisper.load_audio("audio.mp3")
audio = whisper.pad_or_trim(audio)

# make log-Mel spectrogram and move to the same device as the model
mel = whisper.log_mel_spectrogram(audio).to(model.device)

# detect the spoken language
_, probs = model.detect_language(mel)
print(f"Detected language: {max(probs, key=probs.get)}")

# decode the audio
options = whisper.DecodingOptions()
result = whisper.decode(model, mel, options)

# print the recognized text
print(result)

В результате выполнения скрипта мы получим следующий вывод в формате json:

{'text': ' Солнечное излучение (большой текст обрезал).',
 'segments': [
{'id': 0, 'seek': 0, 'start': 0.0, 'end': 4.84, 'text': ' Солнечное излучение является одним из самых мощных источников энергии в Селенной.', 'tokens': [50364, 2933, 20470, 4310, 6126, 3943, 41497, 5627, 29755, 50096, 3943, 37241, 39218, 5783, 12410, 20483, 22122, 40804, 7347, 740, 2933, 21180, 5007, 13, 50606], 'temperature': 0.0, 'avg_logprob': -0.1954750242687407, 'compression_ratio': 2.0371621621621623, 'no_speech_prob': 0.0006262446404434741},
{'id': 1, 'seek': 0, 'start': 4.84, 'end': 8.4, 'text': ' Оно поступает на Землю в виде видимого и инфракрасного света,', 'tokens': [50606, 3688, 1234, 43829, 3310, 1470, 42604, 6578, 740, 12921, 38273, 2350, 1006, 6635, 3619, 481, 1272, 17184, 4699, 4155, 20513, 11, 50784], 'temperature': 0.0, 'avg_logprob': -0.1954750242687407, 'compression_ratio': 2.0371621621621623, 'no_speech_prob': 0.0006262446404434741},
{'id': 2, 'seek': 0, 'start': 8.4, 'end': 10.56, 'text': ' а также в виде космических лучей.', 'tokens': [50784, 2559, 16584, 740, 12921, 31839, 919, 38911, 15525, 2345, 13, 50892], 'temperature': 0.0, 'avg_logprob': -0.1954750242687407, 'compression_ratio': 2.0371621621621623, 'no_speech_prob': 0.0006262446404434741}, 
{'id': 3, 'seek': 0, 'start': 10.56, 'end': 15.8, 'text': ' Солнечное излучение имеет мощность примерно в квадратный метр поверхности Земли.', 'tokens': [50892, 2933, 20470, 4310, 6126, 3943, 41497, 5627, 33761, 39218, 9930, 37424, 740, 35350, 2601, 11157, 4441, 18791, 481, 44397, 13975, 42604, 1675, 13, 51154], 'temperature': 0.0, 'avg_logprob': -0.1954750242687407, 'compression_ratio': 2.0371621621621623, 'no_speech_prob': 0.0006262446404434741},
{'id': 4, 'seek': 0, 'start': 15.8, 'end': 19.44, 'text': ' Это достаточно для производства электроэнергии и других применений.', 'tokens': [51154, 6684, 28562, 5561, 28685, 12115, 31314, 9938, 7570, 489, 19480, 7347, 1006, 31211, 31806, 1008, 17271, 13, 51336], 'temperature': 0.0, 'avg_logprob': -0.1954750242687407, 'compression_ratio': 2.0371621621621623, 'no_speech_prob': 0.0006262446404434741}
], 'language': 'ru'}

Как можно видеть из содержания вывода, Whisper выводит довольно информативный результат транскрибации, с разбивкой текста на сегменты и указанием временных меток, языка и другой информации. Используя эту информацию, мы можем сформировать удобный и красивый текстовой файл расшифровки аудио.

Улучшения приходят в процессе творчества


Изначально планировалось реализовать только текстовую расшифровку аудиофайла(песни), но потом мелькнула мысль: «Почему бы не реализовать перевод текста, отличного от родного языка? Ведь так можно узнать смысл любимой песни всего за пару минут.»
Для реализации этой идеи, я решил пойти простым путём: использовать API сервисов онлайн переводчиков. К сожалению, а может и к счастью, это была моя ошибка, ввиду ограничения запросов на данные API. Ну что ж, подумал я, меньше внешних сервисов — больше свободы, будем использовать локальные алгоритмы перевода на базе нейронных сетей.

Оффлайн перевод с помощью нейронных сетей


Трансформеры нам помогут! Не долго размышляя, решил использовать библиотеку Transformers, которая позволяет применять в своих проектах большое количество моделей, полный список можно посмотреть на сайте проекта.

Для оффлайн перевода будем использовать модели Helsinki-NLP от Группы исследований языковых технологий Хельсинкского университета.

Пример Python скрипта для оффлайн перевода:

from transformers import pipeline
import torch

device = "cuda:0" if torch.cuda.is_available() else "cpu"
torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
# устанавливаем количество потоков для torch
torch.set_num_threads(4)
#Перевод с любого языка на английский
translator   = pipeline("translation", model="Helsinki-NLP/opus-mt-mul-en")
text         = "Какой-то текст на любом языке"
translation  = translator(text)
print(translation[0]['translation_text'])

В примере указан мультиязычный перевод на английский. К сожалению, у Helsinki-NLP нет мультиязычных моделей с переводом на русский язык, поэтому воспользуемся трюком большинства генеративных ИИ и применим двойной перевод, то есть в нашем случае, с английского языка на русский, используя модель Helsinki-NLP/opus-mt-en-ru.

Ну что ж, давайте собирать бота


Учитывая все свои размышления по функциональности бота, получился следующий результат, для удобного восприятия кода, я разделил скрипт на несколько файлов:

Главный скрипт, отвечающий за работу бота tg_bot.py
import neural_process
import file_process
import config
from telegram import ForceReply, Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, CallbackContext, ContextTypes


# Функция, которая вызывается при отправке файла
async def handle_file(update: Update, context: CallbackContext):
    audio = update.message.audio
    print(audio.mime_type)
    # Сохраняем аудиофайл локально и получаем путь сохранения
    file = await context.bot.get_file(audio.file_id)
    f_name = audio.file_name
    local_file_path = file_process.save_audio_file(f_name, file.file_path)
    # Отправляем сообщение о успешном сохранении аудиофайла
    await update.message.reply_text(
        f"Подождите немного, я в ускоренном темпе прослушаю аудиофайл {f_name} и отправлю вам текстовую "
        f"расшифровку с переводом."
    )
    # проверяем файл на длительность
    status = True
    status_dur = True
    try:
        file_dur = file_process.file_duration_check(local_file_path)
        if file_dur > 600:
            status_dur = False
    except Exception as e:
        print(f"Возникла ошибка: {e}")
        status_dur = False

    # Получаем перевод

    trance_text = 'в эту переменную сохраняется текст транскрибации с переводом'
    if status_dur:
        try:
            start_time = time.time()
            trance_text = neural_process.final_process(local_file_path, f_name)
        except Exception as e:
            print(f"Возникла ошибка: {e}")
            status = False
    else:
        status = False
        await update.message.reply_text(
            f"Сожалею, но возникла ошибка обработки файла. Длительность файла не должна превышать десять минут."
        )

    if status:
        # Сохраняем вывод в текстовый файл
        tx_file_path = file_process.save_text_to_file(f_name, trance_text)
        end_time = time.time()
        process_time = round(end_time - start_time, 2)
        await update.message.reply_text(
            f"Перевод готов! Ловите файл с переводом. Затраченное время: {process_time} сек.  "
        )
        # Отправляем текстовый файл
        with open(tx_file_path, "rb") as document:
            await context.bot.send_document(chat_id=update.message.chat_id, document=document)

    else:
        file_process.delete_file(local_file_path)
        if status_dur:
            await update.message.reply_text(
                f"Сожалею, но возникла ошибка обработки файла. Пожалуйста, убедитесь что файл имеет правильный аудиоформат."
            )

# Функция для команды /start
async def start(update: Update, context: CallbackContext) -> None:
    await update.message.reply_text(
        'Привет! Добро пожаловать! Нравится песня, но ты не знаешь о чем она? Я могу перевести песню с любого языка '
        'на русский за считанные секунды, просто скинь аудио файл в чат. ')


# Функция для команды / help
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Send a message when the command /help is issued."""
    user = update.effective_user
    await update.message.reply_html(
        rf"{user.mention_html()}, я могу перевести песню с любого языка на русский, просто скинь аудио файл в чат.",
        reply_markup=ForceReply(selective=False),
    )

# Запускаем бота
def main():
    # Токен  бота
    application = Application.builder().token(config.tg_key).build()
    # Регистрируем обработчик для команды /start
    application.add_handler(CommandHandler("start", start))
    application.add_handler(CommandHandler("help", help_command))
    # Регистрируем обработчик для приема файлов
    application.add_handler(MessageHandler(filters.AUDIO, handle_file))
    # Запускаем бота
    application.run_polling()
    # Останавливаем бота при нажатии Ctrl+C
    application.idle()


if __name__ == '__main__':
    main()


Скрипт для работы с файлами file_process.py
import requests
import os
from pydub.utils import mediainfo


def delete_file(file_path):
    try:
        os.remove(file_path)
        print(f"Файл {file_path} успешно удален.")
    except FileNotFoundError:
        print(f"Файл {file_path} не найден.")
    except Exception as e:
        print(f"Произошла ошибка при удалении файла {file_path}: {e}")


def save_audio_file(file_name, file_link):
    response = requests.get(file_link)
    # Сохраняем аудиофайл локально для последующей обработки
    sound_folder = 'sound'
    local_file_path = f"{sound_folder}/{file_name}"

    if not os.path.exists(sound_folder):
        # Если папки нет, создаем её
        os.makedirs(sound_folder)

    with open(local_file_path, 'wb') as local_file:
        local_file.write(response.content)
    return local_file_path


def save_text_to_file(file_name, trance_text):
    text_folder = 'output'
    local_file_path = f"{text_folder}/{file_name.replace('.', '_') + '.txt'}"

    if not os.path.exists(text_folder):
        # Если папки нет, создаем её
        os.makedirs(text_folder)
    # Сохраняем вывод в текстовый файл
    with open(local_file_path, "w", encoding="utf-8") as output_file:
        output_file.write(trance_text)

    return local_file_path


def file_duration_check(file_path):
    audio_info = mediainfo(file_path)
    duration = float(audio_info['duration'])
    print(duration)
    return duration


Скрипт для работы с нейросетями neural_process.py
from transformers import pipeline
import torch
import whisper
import file_process

device = "cuda:0" if torch.cuda.is_available() else "cpu"
torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
# устанавливаем количество потоков для torch
torch.set_num_threads(4)


def sound_to_text(audios):
    model = whisper.load_model("base")
    # load audio and pad/trim it to fit 30 seconds
    audio = whisper.load_audio(audios)
    audio = whisper.pad_or_trim(audio)
    # make log-Mel spectrogram and move to the same device as the model
    mel = whisper.log_mel_spectrogram(audio).to(model.device)
    # detect the spoken language
    _, probs = model.detect_language(mel)
    lang = max(probs, key=probs.get)
    print(f"Detected language: {max(probs, key=probs.get)}")
    # decode the audio
    result = model.transcribe(audios, fp16=False, language=lang)
    print(result)
    return result['segments'], lang


def final_process(file, file_name):
    print(f"Сохранен в директории: {file}")
    raw, detected_lang = sound_to_text(file)
    translator = pipeline("translation", model="Helsinki-NLP/opus-mt-mul-en")
    translator2 = pipeline("translation", model="Helsinki-NLP/opus-mt-en-ru")
    text = f"Перевод аудиофайла: {file_name} \n"
    text += f"В файле используется {get_language_name(detected_lang)} язык. \n"
    for segment in raw:
        text += "-------------------- \n"
        text += f"ID элемента: {segment['id']} Начало: {int(segment['start'])} --- Конец: {int(segment['end'])} \n"
        text += f"Исходный текст:{segment['text']} \n"
        if detected_lang == 'en':
            text_en = segment['text']
            translation2 = translator2(text_en)
            text += f"Перевод: {translation2[0]['translation_text']} \n"
        elif detected_lang == 'ru':
            text += ""
        else:
            translation = translator(segment['text'])
            text_en = translation[0]['translation_text']
            translation2 = translator2(text_en)
            text += f"Перевод: {translation2[0]['translation_text']} \n"

    file_process.delete_file(file)
    print(text)
    return text


def get_language_name(code):
    languages = {
        'ru': 'русский',
        'en': 'английский',
        'zh': 'китайский',
        'es': 'испанский',
        'ar': 'арабский',
        'he': 'иврит',
        'hi': 'хинди',
        'bn': 'бенгальский',
        'pt': 'португальский',
        'fr': 'французский',
        'de': 'немецкий',
        'ja': 'японский',
        'pa': 'панджаби',
        'jv': 'яванский',
        'te': 'телугу',
        'ms': 'малайский',
        'ko': 'корейский',
        'vi': 'вьетнамский',
        'ta': 'тамильский',
        'it': 'итальянский',
        'tr': 'турецкий',
        'uk': 'украинский',
        'pl': 'польский',
    }
    return languages.get(code, 'неизвестный язык')


Для запуска скрипта, нам необходимо установить следующие пакеты, используя команду:

pip install transformers, torch, accelerate, sentencepiece, sacremoses, python-telegram-bot, openai-whisper, pydub

Деплоим бота


Чтобы всё заработало, не деплойте по пятницам.

Наш телеграмм бот, который работает с использованием алгоритмов ML, достаточно требователен к аппаратным ресурсам. Да, вы можете его использовать на своем ПК, но целесообразнее использовать облачные сервисы для подобных целей. Для размещения своего телеграмм бота, я решил воспользоваться облачной инфраструктурой от компании Timeweb Cloud. Итак, приступим к созданию нашего облачного сервиса.

Для создания нашего облачного сервиса, нам необходимо войти в панель управления под вашей учетной записью. Если учетная запись не создана, то регистрация учетной записи займет всего пару кликов. Я, например, для регистрации и авторизации воспользовался аккаунтом Google.
Создаем новый сервис, выбираем пункт «Облачный сервер»:



Далее нам предстоит выбрать операционную систему для нашего сервера, я выбрал Debian 11:



Выбираем расположение нашего сервера:



Выбираем конфигурацию сервера, как видно на изображении, для начальных тестов я выбрал конфигурацию с четырьмя ядрами CPU и 8 ГБ оперативной памяти:



Вот и все основные опции, которые нам нужны для создания сервера, остальные опции выбираете/указываете по желанию. Чтобы создать сервер, необходимо нажать кнопку «Заказать», которая располагается справа.

Процесс создания сервера:



Сервер создан:



После успешного создания сервера, перейдя в дашборд, с правой стороны вы увидите параметры для удаленного подключения к созданному серверу:



Всё выполняется очень быстро, на создание сервера ушло пару минут.

Подключаемся, настраиваем сервер для размещения бота


Далее мы будем использовать терминал для подключения к нашему облачному сервису.
Запускаем терминал и подключаемся к нашему серверу, после подключения нам необходимо проверить и установить доступные обновления:

apt-get update & upgrade

Запускать скрипты с root правами — как минимум, это дурной тон, поэтому создаем нового пользователя с именем tg_bot:

adduser tg_bot

и пропишем его в группу sudo:

usermod -aG sudo tg_bot

Затем нам нужно выйти из учетной записи root и подключиться под новым пользователем tg_bot, и далее все операции мы будем выполнять под этой учетной записью.

Проверим обновления Python3 и установим, если имеются:

sudo apt update
sudo apt install python3

Установим менеджер пакетов:

sudo apt install python3-pip

Так как мы работаем с медиафайлами, нам необходимо установить набор библиотек FFmpeg:

sudo apt install ffmpeg

Для оценки производительности, на всякий случай установим системный монитор htop:

sudo apt install htop

Устанавливаем необходимые зависимости, для работы нашего бота:

pip install transformers, torch, accelerate, sentencepiece, sacremoses, python-telegram-bot, openai-whisper, pydub

Создаем рабочую директорию для нашего бота:

mkdir tg_bot_scripts

Затем переходим в созданную директорию, с помощью команды:

cd tg_bot_scripts

Далее нам нужно создать файлы Python нашего бота, с помощью команд:

nano tg_bot.py

В открывшемся текстовом редакторе вставляем код из статьи и сохраняем, аналогично поступаем и с другими файлами.

nano neural_process.py

nano file_process.py

nano config.py

файл config.py содержит только одну переменную, которая содержит секретный ключ телеграм-бота.

tg_key = 'Ваш секретный ключ'

Или можно воспользоваться альтернативным способом, клонировав код моего репозитория GitHub.

Чтобы это реализовать, нам нужно установить Git:

 sudo apt install git 

После установки Git, выполнить следующую команду:

 git clone https://github.com/VGCH/music_translate_AI_bot 

Далее нам необходимо переименовать созданную при клонировании Git репозитория папку music_translate_AI_bot с помощью команды:

 mv music_translate_AI_bot tg_bot_scripts

После этого важно не забыть добавить токен телеграмм бота:

 cd tg_bot_scripts
 nano config.py

Настало время тестового запуска, запустим нашего бота с помощью команды:

pyton3 tg_bot.py

Если бот запустился и работает нормально, то нам необходимо создать системный сервис для автоматического запуска нашего бота. Создадим файл нашего сервиса с помощью команды:

sudo nano /etc/systemd/system/AI_tg_bot.service

Копируем в открывшийся текстовый редактор следующее содержимое и сохраняем файл:

[Unit]
Description=My AI Bot service
After=network.target

[Service]
WorkingDirectory=/home/tg_bot/tg_bot_scripts/
User=tg_bot
Type=simple
Restart=always
ExecStart=/usr/bin/python3 /home/tg_bot/tg_bot_scripts/tg_bot.py > /dev/null

[Install]
WantedBy=multi-user.target

Чтобы добавить созданный сервис в автозагрузку, используйте следующую команду:

 sudo systemctl enable AI_tg_bot.service 

Чтобы запустить сервис используйте команду:

 sudo systemctl start AI_tg_bot.service 

Чтобы остановить:

 sudo systemctl stop AI_tg_bot.service 

Изменение конфигурации сервера


В процессе отладки бота, я решил изменить конфигурацию своего облачного сервера с четырехъядерного CPU на восьмиядерный. Захожу в панель управления Timeweb Cloud, выбрав свой сервер, перехожу на вкладку «конфигурация»:


И выбираю необходимую конфигурацию:


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

При изменении количества ядер CPU на облачном сервере, так же необходимо в скрипте обработки neural_process.py изменить количество потоков для torch, в моем случае на восемь.

torch.set_num_threads(8)

После изменения конфигурации сервера, можно понаблюдать нагрузку при обработке аудиофайла нашим телеграмм ботом с помощью команды:

htop


Заключение


В итоге у нас получился интересный эксперимент реализации телеграм-бота с применением алгоритмов машинного обучения для распознавания речи и перевода текста.
Спасибо вам за уделенное время и внимание. Есть вопросы, критика, осуждение? Добро пожаловать в комментарии. :)

Небольшой бонус к статье. Переводы песен с помощью созданного бота
Перевод аудиофайла: Lana Del Rey West Coast.mp3
В файле используется английский язык.

ID элемента: 16 Начало: 99 — Конец: 103
Исходный текст: Who baby, who baby, I'm in love
Перевод: Кто, детка, я влюблен.

ID элемента: 17 Начало: 103 — Конец: 113
Исходный текст: I'm in love
Перевод: Я влюблен.

ID элемента: 18 Начало: 113 — Конец: 123
Исходный текст: Don't know the west coast, they got their icons
Перевод: Не знаю западного побережья, у них есть иконы.

ID элемента: 19 Начало: 123 — Конец: 127
Исходный текст: They serve as others, their queens are psychons
Перевод: Они служат как другие, их королевы — психи.

ID элемента: 20 Начало: 127 — Конец: 132
Исходный текст: You, good to music, you, good to music and you
Перевод: Ты, хорош в музыке, ты, хорош в музыке и ты

ID элемента: 21 Начало: 132 — Конец: 134
Исходный текст: Don't you
Перевод: А ты нет?

ID элемента: 22 Начало: 134 — Конец: 139
Исходный текст: Don't know the west coast, they love their movies
Перевод: Не знаю западного побережья, они обожают свои фильмы.

ID элемента: 23 Начало: 139 — Конец: 142
Исходный текст: They're golden-guzzing, rocking all copies
Перевод: Они хвастаются золотом, раскачивают все копии.

ID элемента: 24 Начало: 142 — Конец: 147
Исходный текст: And you, good to music, you, good to music and you
Перевод: А ты, хорош в музыке, ты, хорош в музыке и ты

ID элемента: 25 Начало: 147 — Конец: 149
Исходный текст: Don't you
Перевод: А ты нет?


Перевод аудиофайла: Edis-Banane.mp3
В файле используется турецкий язык.

ID элемента: 0 Начало: 0 — Конец: 1
Исходный текст: Alo.
Перевод: Привет.

ID элемента: 1 Начало: 2 — Конец: 3
Исходный текст: Bir şey söyle.
Перевод: Скажи что-нибудь.

ID элемента: 2 Начало: 6 — Конец: 7
Исходный текст: Bir şey söylemek istemiyorum.
Перевод: Я не хочу ничего говорить.

ID элемента: 3 Начало: 8 — Конец: 9
Исходный текст: Peki o zaman.
Перевод: Ну что ж.

ID элемента: 4 Начало: 30 — Конец: 32
Исходный текст: Bala ne dediğimi soracak olamazsın.
Перевод: Ты не можешь спросить моего отца, что я сказал.

ID элемента: 5 Начало: 32 — Конец: 33
Исходный текст: Hayır.
Перевод: Нет, нет, нет.

ID элемента: 6 Начало: 33 — Конец: 36
Исходный текст: Bir sebebim yok artık kalamam mı?
Перевод: У меня больше нет причин?

ID элемента: 7 Начало: 36 — Конец: 37
Исходный текст: Hayır.
Перевод: Нет, нет, нет.

ID элемента: 8 Начало: 40 — Конец: 42
Исходный текст: Ki göz dilimi.
Перевод: Вот об этом я и говорю.

ID элемента: 9 Начало: 42 — Конец: 43
Исходный текст: Bu ne?
Перевод: Что это?


Продемонстрирована только часть перевода, более информативная, чтобы не раздувать и так большую статью.


Полезные ссылки:





Возможно, захочется почитать и это:


Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Нравятся ли вам подобные статьи?
73.91% Да34
17.39% Нет8
13.04% Не определился ещё6
Проголосовали 46 пользователей. Воздержались 2 пользователя.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 30: ↑30 и ↓0+30
Комментарии22

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud