Пусть интернет прогнётся под нас

в 10:51, , рубрики: Google Chrome, javascript, браузеры, интернет, плагин
Пусть интернет прогнётся под нас - 1

Если ты давно хотел сделать Интернет чуточку лучше (для себя), но всё было лень некогда, то сегодня твой счастливый день. За 21 шаг я покажу тебе как достичь этой заветной цели.

Бесплатный бонус! В добавок к интернету, делаем лучше и Google search. Может, Google оценит идею и предложит руку и сердце (если что, я согласная, даже учебу брошу ради него).
</шутка>

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

Плагин позволят легко внедрять собственный JavaScript/CSS на любые страницы просматриваемые в браузере. Т.е. этакий chrome extensions API версия lite.

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

TL;DR

Если кому-то не хочется читать про все 21 шага, то можно обойтись пятью.

Код плагина можно загрузить с GitHub.

И после этого:

  1. Открыть chrome://extensions в Хром браузере
  2. Поставить галочку напротив «Developer mode»
  3. Нажать на «Load unpacked extensions» и выбрать директорию в которую сохранили исходный код
  4. После этого для плагина «CustomActions» выбрать «options»
  5. На форме опций, нажать «Demo config» и «Save»

Пусть интернет прогнётся под нас - 2

Вот и все, можно пользоваться, например открыть google.com или Хабр, поправить конфигурацию или поиграться со скриптами.

Дальше идет развернутая версия, с лирическими отступлениями.

Если верить исследованиям британских ученных, то сферический интернет пользователь спасаясь от объема информации которую хотят на него вывалить, создает себе уютный интернет мирок из 5-10 сайтов которые посещает более-менее постоянно, а в «большой» интернет выходит случайно и поскорее бежит обратно.

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

Но еще печальнее было осознать, то что мои 9-ть с половиной сайтов не идеальны. Поэтому в рамках курсовой работы было решено сделать их (ну заодно и остальной интернет) лучше.

В большинстве случаев, для этого требовалось написать всего 3-4 строчки на JavaScript и/или 2-3 на CSS.

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

Определяем наши хотелки:

  1. При открытии сайта внедряется пользовательский Ж-скрипт ассоциированный с этим сайтом
  2. Пользовательский скрипт конфигурируется на странице предпочтений
  3. В тулбар браузера добавляем кнопочку для быстрого вызова конфигурации

Техзадание определено, начинаю кодить.

Шаг 1.

Читаю getstarted до просветления, или посинения, смотря с какой стороны посмотреть.

Шаг 2.

Определяю структуру проекта

options.html
options.js

popup.html
popup.js

background.js

manifest.json

icon.png
images
imagesicon128.png
imagesicon16.png
imagesicon48.png

Шаг 3.

Создаю манифест.

{
  "name": "CustomActions",
  "description": "plugin for CustomActions",
  "version": "1.0",
  "background" : {
    "scripts": ["background.js"]
  },
  "icons":
   {
      "128": "images/icon128.png",
      "16": "images/icon16.png",
      "48": "images/icon48.png"
   },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "permissions": [
    "webRequest", "tabs", "activeTab", "http://*/*", "https://*/*", "storage", "unlimitedStorage", "contextMenus", "<all_urls>"
  ],
  "browser_action": {
      "default_title": "Custom Actions Injection plugin",
      "default_icon": "icon.png",
      "default_popup": "popup.html"
  },
        "commands": {
          "cmd-exec-1": {
            "suggested_key": {
              "default": "Ctrl+Q"            
	     },
            "description": "Custom Action #1"
          },
          "cmd-exec-2": {
            "suggested_key": {
              "default": "Ctrl+B"
            },
            "description": "Custom Action #2"
          },
          "cmd-exec-3": {
            "suggested_key": {
              "default": "Ctrl+Y"
            },
            "description": "Custom Action #3"
          }
},
  "options_page": "options.html",
  "manifest_version": 2
}

Шаг 4.

Работаю над формой options.html.

Не зря посещала предыдущую сессию. Пригодились знания по knockout.js из курса «Дизайн Веб-страниц 2.0»

Добавляю файлы knockout-3.4.1.js и knockout.mapping-latest.js в структуру проекта.

Ничего не работает. Копаю. Не помогает. Рою. Нарыла что chrome extensions не любит knockout.
Заставляю полюбить, обновляю манифест.

"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"

Шаг 5.

После боли и страданий в попытке реализовать пользовательский интерфейс приходит озарение.

Тянем всё из chrome://settings

Встали на плечи гиганта и дело пошло:

Пусть интернет прогнётся под нас - 3

Шаг 6.

Надо сохранять данные. Читаю про chrome.storage
Дилемма выбора между chrome.storage.sync и chrome.storage.local

chrome.storage.sync привлекательней, но жёсткие лимиты.

Еще одно озарение – у меня же тестовый проект, так что local — это наше всё.
Правлю манифест чтобы не было проблем с лимитами.

И пользую chrome.storage.local.set для сохранения конфигурации.

            chrome.storage.local.set(items, function () {
                self.status('Items saved.');
                setTimeout(function () { self.status(''); }, 750);
            });

Шаг 7.

Понимаю, что сохраненная конфигурация должна быть перезагружена в background.js, но как???

Ага, сладкая парочка chrome.runtime.sendMessage:

            chrome.storage.local.set(items, function () {
                self.status('Items saved.');
                setTimeout(function () { self.status(''); }, 750);

                chrome.runtime.sendMessage({ command: 'refreshConfig' });
            });

и chrome.runtime.onMessage:

            chrome.runtime.onMessage.addListener(
               function (request, sender, sendResponse) {
                   onCommand(request.command);
            });

Шаг 8.

Открываю popup.js. Закрываю. Снова открываю. И так 7 раз. Ничего не понятно.

Это я одна такая или где? Наступает долгожданное утро и тут споткнулась о samples — спасибо вам невидимые труженики гугла.

Разгребаю завалы драгоценных знаний. В конце концов нахожу нужный пример и использую волшебную комбинацию ACV ( Ctrl-A / Ctrl-C / Ctrl-V ). 13 часов спустя — дело сделано.

Шаг 9.

Пора приступать к главной части, перехват и внедрение.

Перехватчик — chrome.tabs.onUpdated:

chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
        var url = '';
        if (changeInfo && changeInfo.url)
            url = changeInfo.url.toLowerCase();
        else if (tab && tab.url)
            url = tab.url.toLowerCase();

	// . . .
});

и внедренец — chrome.tabs.executeScript:


if (item.sourceType == 'InjectCSS') 
	chrome.tabs.insertCSS(item.output == 'Owner tab' ? tabId : null, { code: item.data });
else
	chrome.tabs.executeScript(item.output == 'Owner tab' ? tabId : null, { code: item.source });

Все прошло легче чем могло, но всё же дольше чем хотелось.

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

Шаг 10.

Всё готово. Можно идти за честно заработанной пятеркой…

… не так быстро. Вместо пятерки принесла кучу замечаний и улучшений.

Шаг 11.

Начнем с самого легкого, в popup.js добавляю поддержку функции «добавить текущий сайт»

Нет так легко как кажется, надо передать данные из popup.js в options.js.

Потратила 3 часа пытаясь поймать messages, сохранить в extension storage и прочитать обратно, но в конце концов решила, что умный в гору не пойдет, он её обойдет.

Query string – наш ответ этой горе.

Шаг 12.

Следующая функция посложнее, добавить поддержку context menu.
Примеры снова спасают. Всё оказывается проще чем могло бы — chrome.contextMenus.create:

chrome.contextMenus.create({ id: item.id, 
				contexts: ["page", "frame", "selection"], 
				title: item.name, 
				onclick: function (info, tab) { 
							onCommand(info.menuItemId); 
					} 
});

Шаг 13.

Не верю в приметы, но оказался не счастливый шаг.

Надо сделать поддержку вызова через горячие клавиши.

Всё делаю как в примерах, правлю манифест:

"commands": {
          "cmd-exec-1": {
            "suggested_key": {
              "default": "Ctrl+Q"            
	     },
            "description": "Custom Action #1"
          }
}

и использую chrome.commands.onCommand:

chrome.commands.onCommand.addListener(onCommand);

Не работает. После двух дней и ночей и медикаментозной интервенции, промотав вниз страничку настроек обнаруживаю неприметную ссылку на нужные установки.

Пусть интернет прогнётся под нас - 4
Вот где собака зарылась. Сама виновата, слишком много примеров наставила, вот ссылочка и сбежала на следующий экран.

Шаг 14.

У нашего завлаба, жена преподаватель английского. Требует перевести интерфейс. Придется учить английский.

Технологии приходят на помощь. Гугл транслейт vs преподаватель английского – счёт 1: 0 в пользу гугла животворящего!

Шаг 15.

Опять идем за пятёркой, но в душе уже согласна на четвёрку.

Возвращаюсь с новым словом рефакторинг (до этого знала только слово ФАКторинг. Особенно часто говорила его в начале проекта).

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

Похоже у завлаба в добавок к английской жене, есть любовница с кафедры UX/UI.
Но вспомнив про добрых людей из гугло-примеров, почти не сопротивлялась.

Шаг 16.

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

Шаг 17.

Добавляю пример для lorem ipsum. Кто это посоветовал? Учитель латыни?

Требование: при нажатии на комбинацию клавиш заполняем поля на форме предложениями языка lorem.

Сделала, вроде работает.

    var loremDemoData = {
        names: [
            { firstName: "Victoria", lastName: "Veit", email: "Victoria.Veit@noreply.ru" },
            { firstName: "Gisele", lastName: "Gillard", email: "Gisele.Gillard@noreply.ru" },
            { firstName: "Edmund", lastName: "Edelson", email: "Edmund.Edelson@noreply.ru" },
            { firstName: "Joey", lastName: "Janelle", email: "Joey.Janelle@noreply.ru" }
        ],
        lorem: [
            "Orem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet purus condimentum, porta nulla sed, consequat felis. Phasellus quis condimentum odio. Maecenas scelerisque vehicula leo, sit amet tristique tellus molestie sed. Aenean lacus lorem, feugiat semper imperdiet a, vehicula ac orci. Pellentesque ac nisi commodo, pellentesque lorem quis, fringilla tellus. Fusce bibendum erat sit amet libero maximus rutrum. Integer dictum nibh sodales efficitur congue. Mauris nulla libero, hendrerit eget dictum nec, aliquam eu mi. Donec ipsum nisi, bibendum et consequat eu, imperdiet eget nisl. Duis tincidunt nibh et nibh tempor, quis mattis mi vulputate.",
            "Suspendisse quis eleifend lectus. Sed nec vehicula elit. Praesent ac sollicitudin diam. Nam at venenatis lectus. Fusce condimentum tortor nec augue vestibulum tempus. Nullam faucibus vehicula lorem, et mollis justo dapibus a. Proin sagittis velit in lectus vehicula, id eleifend urna hendrerit. Integer rhoncus dui sed enim sollicitudin, a finibus magna fermentum.",
            "Fusce at urna vitae magna semper scelerisque id volutpat tellus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed ut elit nisl. Duis sit amet ante accumsan nibh ultricies pharetra at vitae purus. Donec a felis eget ipsum euismod tempus. Donec elementum vel tortor vel efficitur. Nunc tristique, magna hendrerit sagittis placerat, odio sem commodo ligula, eu aliquam arcu elit sit amet diam. Etiam ultrices vehicula auctor."
        ],
        loremShort: [
            "Morbi nec sollicitudin augue.",
            "Suspendisse sagittis fringilla aliquam.",
            "Curabitur malesuada dolor.",
            "Praesent quis lacus neque. Duis vitae vehicula felis"
        ]
    };

        function getRandomInt(min, max) {
            return Math.floor(Math.random() * (max - min)) + min;
        }

        var name = data.names[getRandomInt(0, data.names.length)];
        var hadEmail = false;

        var t = document.querySelectorAll('input[type=text], textarea');
        for (var i = 0, l = t.length; i < l; i++) {
            var e = t[i];
            var ro = e.getAttribute('readonly');

            if (e.disabled || ro === '' || ro === 'true' || ro == '1')
                continue;

            var loremTxt = data.lorem[getRandomInt(0, data.lorem.length)];
            var loremShort = data.loremShort[getRandomInt(0, data.loremShort.length)];

            var na = ('' + e.name).toLowerCase();
            var ia = ('' + e.id).toLowerCase();

            if (na == 'firstname' || ia == 'firstname' || na == 'fname' || ia == 'fname')
                e.value = name.firstName;
            else if (na == 'lastname' || ia == 'lastname' || na == 'lname' || ia == 'lname')
                e.value = name.lastName;
            else if (!hadEmail && (na.indexOf('email') >= 0 || ia.indexOf('email') >= 0)) {
                e.value = name.email;
                hadEmail = true;
            } else {
                e.value = (e.tagName == 'TEXTAREA' ? loremTxt : loremShort);
            }
        }

Шаг 18.

Следующий пример для сайта habrahabr.ru

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

В процессе узнала о существовании Хабра и Гиктаймса. Знала бы о них раньше, все было бы по крайней мере в два раза быстрее. Провела три дня читая без остановки. Осилила статьи только за последний год, впереди еще много интересного!

На следующем приёме, мой психотерапевт посоветовал написать статью о своих мучениях. Говорит, что это важный шаг в процессе лечения психологической травмы, нанесенной работой над этой курсовой.

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

Оказалось, что статью написать легче, чем код. Может чукча не читатель, а писатель?

{
    "showCompanies": [
        "yandex",
        "mosigra"
    ],
    "hideCompanies": [
        "hashflare"
    ],
    "hideHubs": [
        "lib"
    ]
}

        function hideParent(el) {
            if (el.classList && el.classList.contains('post_teaser'))
                el.style.display = 'none';
            else if (el.parentElement)
                hideParent(el.parentElement);
        }

        function sanitizeParent(el) {
            if (el.classList && el.classList.contains('post_teaser')) {
                el.querySelectorAll('img').forEach(function (img) { img.style.display = 'none'; });
                el.querySelectorAll('.post__body_crop').forEach(function (chld) {
                    chld.style.maxHeight = '4em';
                    chld.style.overflow = 'hidden';

                    el.addEventListener('mouseover', function () {
                        chld.style.maxHeight = "inherit"; chl
                        d.querySelectorAll('img').forEach(function (img) {
                            img.style.display = 'block';
                        });
                    }, false);

                    el.addEventListener('mouseout', function () {
                        chld.style.maxHeight = "4em";
                        chld.querySelectorAll('img').forEach(function (img) {
                            img.style.display = 'none';
                        });
                    }, false);

                });

                el.querySelectorAll('.post__title a').forEach(function (titl) { titl.style.color = '#707040'; });

            } else if (el.parentElement)
                sanitizeParent(el.parentElement);
        }

        document.querySelectorAll('a[href*="https://geektimes.ru/hub/"]').forEach(function (el) {
            var hub = el.getAttribute('href').replace(/^.*.ru/hub//, '').replace(//.*$/, '');
            if (data && data.hideHubs && data.hideHubs.indexOf(hub) >= 0)
                hideParent(el);
        });

        document.querySelectorAll('a[href*="https://geektimes.ru/company/"], a[href*="https://habrahabr.ru/company/"]').forEach(function (el) {
            var company = el.getAttribute('href').replace(/^.*.ru/company//, '').replace(//.*$/, '');
            if (data) {
                if (data.hideCompanies && data.hideCompanies.indexOf(company) >= 0) {
                    hideParent(el);
                    return;
                } else if (data.showCompanies && data.showCompanies.indexOf(company) >= 0)
                    return;
            }

            sanitizeParent(el);
        });

Шаг 19.

Бродя по просторам интернета, увидела интересную идею и решила реализовать её в качестве примера для google search.

Может завлаб оценит инициативу и перестанет придираться по пустякам.

Функционал: при открытии гугла, показываем подсказку со списком наиболее часто используемых слов и сайтов для быстрого поиска.

После примера для Харба, это пара пустяков.

    var googleDemoData = [
        { "keywords": "python", "title": "python" },
        { "keywords": "javascript", "title": "javascript" },
        { "keywords": "php", "title": "php" },
        { "keywords": "mysql", "title": "mysql" },
        { "keywords": "site:stackoverflow.com", "title": "at stackoverflow.com" },
        { "keywords": "site:developer.mozilla.org", "title": "at developer.mozilla.org" },
        { "keywords": "site:developer.chrome.com", "title": "at developer.chrome.com" },
        { "keywords": "site:habrahabr.ru", "title": "at habrahabr.ru" }
    ];

        function ggSetTimeRange() {
            var elemId = this.getAttribute('data-range');
            var timeLimit = document.querySelector('#' + elemId + ' a');
            if (timeLimit)
                timeLimit.click();
        }

        function ggReplaceAndSearch() {
            var kw = this.getAttribute('data-search');

            if (document.location.href.indexOf('chrome-search://') == 0 || document.location.href.indexOf('https://www.google.com/_/chrome/newtab?') == 0) {
                document.location.href = "https://www.google.com/search?q=" + encodeURIComponent(kw);
                return;
            }

            var inputText = document.querySelector('input[name="q"]');
            if (inputText) {
                setTimeout(function () {

                    var keyword = '' + inputText.value;
                    if (kw.indexOf('site:') >= 0 && keyword.indexOf('site:') >= 0) {
                        keyword = keyword.replace(/ *site:[^ ]+/, '');
                    }
                    else if (keyword.indexOf(kw) >= 0)
                        return;

                    kw = ' ' + kw;

                    if (kw.indexOf('site:') >= 0) {
                        inputText.value = keyword + ' ' + kw;

                        setTimeout(function () {
                            var btn = document.querySelector('form[action="/search"]');
                            if (btn) {
                                btn.submit();
                            } else {
                                btn = document.querySelector('button[name="btnK"]');
                                if (btn) {
                                    btn.click();
                                }
                            }
                        }, 100);
                    }
                    else {
                        inputText.value = kw + ' ' + keyword;

                        var strLength = ('' + inputText.value).length;
                        inputText.setSelectionRange(strLength, strLength);
                    }
                }, 200);

                setTimeout(function () {
                    inputText.focus();
                }, 100);

            };
        };

        var ggHelper = document.getElementById('ggHelper');
        if (!ggHelper) {
            var helperHtml = '<div id="ggHelper" style="position: fixed; ' +
					                'box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); ' +
					                'background-color: #f0f0f0; border-radius: 2px; flex: 1; ' +
					                'padding: 0.7em 1em 0.3em 1em; right: 1em; top: 12em; width: 13em; height: ' + (data.length + 4) + 'em; ' +
					                'font-size: 13px;">' +
		            '<ul style="list-style-type: none; margin: 0; padding: 0;">';

            data.forEach(function(dataItem ) {
                helperHtml += '<li style="text-align: left; cursor: pointer;"><a href="javascript: return false;" data-search="' + dataItem.keywords + '" class="gg-keyword">' + dataItem.title + '</a></li>';
            });

            helperHtml += '<li style="margin: 0.5em;"><hr size="1" style="height: 1px; border-color: #e0e0e0;"></li>';

            helperHtml += '<li style="cursor: pointer;">' +
                '<a href="javascript: return false;" data-range="qdr_w" class="gg-range">week</a> :: ' +
                '<a href="javascript: return false;" data-range="qdr_m" class="gg-range">month</a> :: ' +
                '<a href="javascript: return false;" data-range="qdr_y" class="gg-range">year</a> :: ' +
                '<a href="javascript: return false;" data-range="qdr_" class="gg-range">any</a></li>';
            helperHtml += '</ul></div>';

            var bodyTag = document.querySelector('body');
            if (bodyTag) {
                var e = document.createElement('div');
                e.innerHTML = helperHtml;
                bodyTag.appendChild(e.firstChild);

                document.querySelectorAll('#ggHelper .gg-keyword').forEach(function (el) {
                    el.addEventListener('click', ggReplaceAndSearch);
                });

                document.querySelectorAll('#ggHelper .gg-range').forEach(function (el) {
                    el.addEventListener('click', ggSetTimeRange);
                });
            }
        }

Шаг 20.

По заветам хабра, отправила код на ревью группе экспертов. Вердикт – всё переписать. Плачу, уже навзрыд.

После переписки, нашла еще не менее 6-ти багов. Ревью – это сила! Надо запомнить и это слово.
Стало приятно смотреть на код, начала даже понимать что и зачем там написано.

Шаг 21.

Все готово.

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

Пожелайте удачи!

P.S.:. В конце хотелось бы выразить сердечную благодарность создателям и пользователям сайта stackoverflow.com

Не представляю как выглядела работа до его создания. А до появления веба (слава ТНБ, я родилась уже после его создания веба), профессия программиста наверное была одной из самых депрессивных и с самым высоким уровнем суицидов на единицу площади.

Даже сейчас, иногда всё еще хочется вырвать всю растительность на голове, а иногда и того похуже.

P.P.S.:. Критика приветствуется. Любая.

Update 1. Выставили оценки, мне 4++.

Один бал завлаб вычел с формулировкой: «слишком много плагиата с StackOverflow», но плюс добавил за статью на Хабре. На вопросы по поводу второго плюса, загадочно ухмыляется в усы.

Update 2. Две радостные новости.

Первая. Преподаватель по сетевым технологиям обещал поставить экзамен автоматом если добавлю функцию «поделиться с другом». Группа экспретов говорила незнакомые слова.

Сижу, курю node.js, походу придется начать не только курить, но и пить. Без бутылки не разобраться.

Голова в облаках. На Хероку оно мне надо? Может лучше сдать как обычно — шпаргалки и зубрило. А то здоровье дороже.

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

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

Озвучьте ваши самые смелые фантазии.

Третья. Если так пойдет, может и диплом на чём-нибудь прокатит?

Update 3. Ура! Первая зарплата в долларах. По программе work & travel изучаю английский на кухне местного макдональдса.

Первая выученная идиома: «маньяна на траваху». Чувствую, что к концу лета буду флюент.

Умножаю доллары на 60, количество нулей конкурирует с лучшими предложениями для программистов в нашем областном центре. А у нас в райцентре, надо вводить коэффициент N.

Нет N мало, надо брать M.

Группа экспертов, молча завидует издалека, но намекает что по приезду будем гудеть. Требуют привезти дудку. Но тут тем кому нету 21 года, музыкальные инструменты не продают, не знаю что и делать.

Много думаю.

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

До скорой встречи!

Автор: ntpetrova

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js