Node, Puppeteer: автоматизация Google Chrome. Сохраняем ts файлы и преобразуем в mp4.

Используем именно Google Chrome, так как в Chromium не все работает, например, Shaka player (Google).
После установки Node.js
sudo dnf module list nodejs
sudo dnf module enable nodejs:12
sudo dnf install nodejs
Устанавливаем puppeteer без Chromium (350 МВ)
echo "export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true && npm install i puppeteer" | sudo sh
При запуске puppeteer указываем путь к исполняемому файлу Google Chrome
 executablePath: '/opt/google/chrome/google-chrome',
Выключаем CORS
        '--disable-web-security',
        '--disable-site-isolation-trials',
Из интересного для сохранения «.m3u8» и «.ts» используем page.evaluate, далее fetch в контексте браузера, затем передаем содержимое файла в puppeteer
let dataString = '[' + (new Uint8Array(data)).toString() + ']';
и сохраняем файл определенной с помощью page.exposeFunction функцией writefile.
'use strict';
const puppeteer = require('puppeteer');
const fs = require('fs');

(async function main() {
  var browser;
  try {

    var myArgs = process.argv.slice(2);
    if (!myArgs.length) {
        throw('need dir');        
    }
    console.log('dir: ', myArgs[0]);


    browser = await puppeteer.launch({
      headless: true,
      slowMo: 250, // slow down by 250ms
//      devtools: true,
      executablePath: '/opt/google/chrome/google-chrome',
      defaultViewport: null, // иначе не работает --window-size=1920,1080
      args:[
        '--window-size=1920,1080',
        '--disable-setuid-sandbox',
	'--no-sandbox',
        '--disable-infobars',
        '--disable-web-security',
        '--disable-site-isolation-trials',
        ],
    });

/*
    var lessonUrl = myArgs[0]

    const crypto = require('crypto');
    const hash = crypto.createHash('sha256');
    hash.update(lessonUrl);

    var dir = 'fdb' + '/' + hash.digest('hex');
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir);
    }
    var dirOriginal = dir + '/' + 'original';

    if (!fs.existsSync(dirOriginal)) {
        fs.mkdirSync(dirOriginal);
    }
*/

    var lessonUrl = myArgs[1];
    var dir = myArgs[0];
    var dirOriginal = dir + '/' + 'original';


    const regUrl = 'https://zzz.ru/user/registration'
    const page = await browser.newPage()
    page.on('console', msg => console.log('PAGE LOG:', msg.text()));
//    await page.setDefaultNavigationTimeout(60000);
    await page.goto(regUrl, { waitUntil: 'load' })
    await page.waitForTimeout(1000);
    await page.screenshot({ path: dir + '/' + 'screenshot01.png' })

    let checkUrl = await page.evaluate(() => location.href);
    if (regUrl == checkUrl) {
        await page.click('[data-qa="__authEmailButton"]')
        await page.type('#email', 'username@mail.ru')
        await page.type('#password', '12wdnj4ss7ty')
        await page.screenshot({ path: dir + '/' + 'screenshot02.png' })

        await Promise.all([
          page.click('[data-qa="__authEmailSubmitButton"]'),
          page.waitForNavigation(), // if "await page.waitForNavigation" then "TimeoutError: Navigation timeout of 30000 ms exceeded", причем на ноде поновее всё работало
        ]);
    }
    await page.waitForTimeout(2000);
    await page.screenshot({ path: dir + '/' + 'screenshot1.png' })
    console.log(lessonUrl);

    await page.goto(lessonUrl, { waitUntil: 'load' })
    await page.waitForTimeout(2000);
    await page.screenshot({ path: dir + '/' + 'screenshot2.png' })
/*
// forEach и async не очень дружат
    var videoFrame;
    page.frames().forEach(async (frame) => {
        let tt = await frame.$('#shaka-video');
        if (tt) {
            videoFrame = frame;
        }
    });
*/

    var videoFrame;
    for (const frame of page.frames()) {
        let tt = await frame.$('#shaka-video');
        if (tt) {
            videoFrame = frame;
        }
    }
    if (!videoFrame) {
            throw('#shaka-video not found!');
    }
    if (videoFrame) {
        console.log(videoFrame.url());
        await page.goto(videoFrame.url(), { waitUntil: 'load' })
        await page.waitForTimeout(5000);
        await page.screenshot({ path: dir + '/' + 'screenshot3.png' })
        const filesUrls = await page.evaluate(() => {
            var urls = [];
            var ui0 = document.getElementById('shaka-player').ui;
            urls.push(ui0.b.Fa);
            // "https://storage.svc.services/api/v2/backends/yandex/sets/hls.webinar::198a2db0-e74b-44c8-ac22-7cec61972e8a/objects/long.v2.yandex.master.m3u8"
            // ссылка после редиректа получаем подписанные (pre-signed) URL
            // ui0.b.s.K
            // "https://storage-lb.services/hls.webinar/198a2db0-e74b-44c8-ac22-7cec61972e8a.long.v2.yandex.master.m3u8?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=DNmtuhzj5w4VFKS-9BUA%2F20201228%2Fru-central1%2Fs3%2Faws4_request&X-Amz-Date=20201228T142459Z&X-Amz-Expires=300&X-Amz-Signature=0a10db545d16abdfd81c988d7f6e488dfc36502aab60456da78afa9f1ed5bac8&X-Amz-SignedHeaders=host"
            var a0=v0=undefined;
            // select audio and video streams
            ui0.b.s.a.forEach((v,k)=>{ 
                if (!a0 && v.stream.type=='audio') {a0=v;} 
                if (v.stream.type=='video' && (!v0 || v.stream.width > v0.stream.width)) {v0=v;} 
            });
            // load and save selected audio and video segments description files
            urls.push(a0.Wc);
            urls.push(v0.Wc);
//      ["https://storage.svc.services/api/v2/backends/yandex/sets/hls.webinar::198a2db0-e74b-44c8-ac22-7cec61972e8a/objects/long.audio.32kbps.1.1608056896.ts" length: 1 __proto__: Array(0)

            a0.stream.segmentIndex.a.forEach((v,k)=>{ 
                urls = urls.concat(v.c());
            });
            v0.stream.segmentIndex.a.forEach((v,k)=>{ 
                urls = urls.concat(v.c());
            });

            return urls;
        });        
        console.log(filesUrls[0], filesUrls[1], filesUrls[2], filesUrls[3], filesUrls.length);

        var filesUrlsSaved = 0;

        await page.exposeFunction('writefile', async (filePath, data) => {
//          let dataBuffer = new TextEncoder().encode(data);
          filesUrlsSaved++;
          let dataBuffer = new Uint8Array(JSON.parse(data));

          return new Promise((resolve, reject) => {
            fs.writeFile(filePath, dataBuffer, {encoding: null}, (err) => {
              if (err)
                reject(err);
              else
                resolve();
            });
          });
        });

        if (filesUrls.length) {
            fs.writeFileSync(dirOriginal + '/loading', filesUrls[1] + '\n' + filesUrls[2] + '\n', {flag: 'w'});
        }

        for (var loadFile of filesUrls) {
            await page.evaluate(async (dirOriginal, loadFile) => {
                let fileName = loadFile.match('[^/]*$')[0].match('^[^#?]*');
                await fetch(loadFile, {
                    method: "GET",
                    redirect: 'follow',
                }).then((response) => {
                    console.log('url with auth', response.url);
                    return response.arrayBuffer();
                }).then(async function(data) {
//                    let dataView = new Uint8Array(data);
//                    let dataString = new TextDecoder().decode(data);
                    let dataString = '[' + (new Uint8Array(data)).toString() + ']';
                    await window.writefile(dirOriginal + '/' + fileName, dataString);
                });
            }, dirOriginal, loadFile);

/*
            // error 403
           var viewSource = await page.goto(loadFile);
           fs.writeFile(dirOriginal + '/' + fileName, await viewSource.buffer(), function (err) {
               if (err) {
                   return console.log(err);
               }
           });
*/
        }

//        if (filesUrls.length) {
//            fs.writeFileSync(dirOriginal + '/loaded', '', {flag: 'w'});
//        }

        if (filesUrls.length != filesUrlsSaved) {
            console.error('filesUrls:', filesUrls.length, filesUrlsSaved);
            throw('Not all files saved!');
        }

    }

    browser.close()
    console.log('See screenshot in: ' + dir)
  } catch (err) {
    console.error(err);
    browser.close()
  }
})();
Еще чуток бэкенда на php
<?php
...
    // выполнение внешней программы и получение stdout, stderr 
    function exec_return($cmd, $input = '') {
        $descriptorspec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
        $proc = proc_open($cmd . "\n", $descriptorspec, $pipes);
        foreach ($pipes as $pipe) {
            stream_set_blocking($pipe, false);
        }
        fwrite($pipes[0], $input); fclose($pipes[0]);
//        $stdout = stream_get_contents($pipes[1]); 
//        $stderr = stream_get_contents($pipes[2]); 
        $stdout = ''; 
        $stderr = ''; 
        while (!feof($pipes[1])) $stdout .= fgets($pipes[1],1024);
        while (!feof($pipes[2])) $stderr .= fgets($pipes[2],1024);
        fclose($pipes[1]);
        fclose($pipes[2]);
        $return = proc_close($proc);
        return [
            'stdout' => $stdout,
            'stderr' => $stderr,
            'return' => $return
        ];
    }
...
                    // копируем все ts в один файл output.mp4 без преобразования, очень быстро
                    // при склейке обычно "убегает" звук, "-itsoffset 0.5", сдвигает видео дорожку на 500 мс
                    $command = sprintf('/usr/bin/ffmpeg -loglevel repeat+level+error -nostats -y -i "%s" -itsoffset 0.147978 -i "%s" -vcodec copy -c:a copy "output.mp4"', $m3u8files[0], $m3u8files[1]);
                    $ret = exec_return($command);
                    file_put_contents("{$forConvert->full}/converted/error", $ret['stderr']);
                    chmod("{$forConvert->full}/converted/error", 0666);                        
                    file_put_contents("{$forConvert->full}/converted/converted", $ret['stdout']);
                    chmod("{$forConvert->full}/converted/converted", 0666);

...

Установка расширения Google Chrome без публикации в интернет-магазине Chrome

Публикацию отклонили, воевать не хочется, расширение бесплатное и необходима только одна (две) установки.

Уважаемый разработчик!
Ваш продукт для Google Chrome (название: "WAFriends", идентификатор: cpjembijgjehhiakijmcdjbncingfdip) нарушил наши правила и был удален из сервиса "Интернет-магазин Chrome".
Ваш продукт нарушает следующий раздел документа "правила программы":
"Спам и размещение в магазине"
Отсутствуют описание объекта в соответствующем поле, значки или скриншоты. Кроме того, объект вызывает подозрения.

1) Если расширение было установлено через "Загрузить распакованное расширение" удаляем.
2) Жмем "Упаковать расширение", выбираем каталог с расширением (ключ не нужен), в итоге появляются два файла zzzz.crx и zzzz.pem 
3) Устанавливаем zzzz.crx посредством drag-and-dropping на страницу расширений chrome://extensions
4) После перезагрузки браузера расширение будет отключено "Unsupported extensions disabled" 
5) Вносим (добавляем) ИД расширения в белый список, ИД на странице расширений отображается не полностью, потому ищем по наименованию каталога
C:\Users\user\AppData\Local\Google\Chrome\User Data\Default\Extensions\koajdlofjfmhmlblclllndnhmjedpnjd
6) Перезагружаем браузер, на странице расширений видим надпись "Этим браузером управляет ваша организация."

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Google\Chrome\ExtensionInstallWhitelist]
"1"="llndpnjdkoajdldnhofjfmhmlblclmje"
"2"="koajdlofjfmhmlblclllndnhmjedpnjd"
Безымянный2 wafriends.crx wafriends.reg

Split a string by whitespace, keeping quoted segments

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

	// вместо простого сплита
	'58 "5 A"'.split(/\s+/);
	// используем матч
	'58 "5 A"'.match(/(?:[^\s"]+|"[^"]*")+/g);
Живой пример использования в фильтре.

	$('#lt').on('keyup paste input', '.filter input', function() {
	    var vals = $(this).val().trim().match(/(?:[^\s"]+|"[^"]*")+/g) || [];
	    // поиск по кол-ву вхождений набора входных значений (разделены пробелом, пробел тоже можно передать в поиск, пример, 100 "5 А")
	    var checkAll = function(val) {
		var finded = 0;
		$.each(vals, function(i,o){ finded += (val.toUpperCase().indexOf(o.replace(/\"/g,'').toUpperCase()) > -1 ? 1 : 0); });
		return vals.length != finded;
	    };
	    $(this).closest('.row').find('.prop-val,.mod').removeClass('hidden').filter(function(){ return vals.length != 0 && checkAll($(this).text()); }).addClass('hidden');
	});

Select2 изменение алгоритма фильтра


    $(".select2foreqtype").select2(
	{ placeholder: 'Укажите тип', 
	  allowClear: true,
	  language: 'ru',
	  minimumInputLength: 1,
	  templateResult: function (data) { // в выпарающем списке отображаем номер
		  if (!data.id) { return data.text; }
		  return $('<div class="clearfix"><span class="pull-left">' + data.text + '</span><span class="pull-right">' + $(data.element).data('srn') + '</span></div>');
	  },
	  matcher: function(params, data) { // поиск не только по наименованию, но и по номеру, причем совместно, разделитель пробел, и без учета порядка
	    var terms = ((params.term || '').trim().toUpperCase()).split(/\s/);
	    var matchCount = 0;
	    var fullText = data.text.toUpperCase() + $(data.element).data('srn');
	    $.each(terms, function(i,term){
		if (fullText.indexOf(term) >= 0) {
			matchCount++;
		}
	    });
	    if (terms.length == matchCount) {
	        return data;
	    }
	    return false;
	  },
	}).on('select2:select', function(e){
console.log(e);
	});

Fly to cart

«Полет в корзину», если у нас нужно «отправлять в полет» только картинку, то все очень легко, пример с перемещением всей карточки и текст описания и картинка. Безымянный15 Свойство zoom не использую, ибо при его использовании расчет координат отличается от браузера к браузеру, использую transform: scale, если не поддерживается, просто «сворачиваю» по высоте.
Ниже код того что на картинке. <style> .card { font-size: 60px; color: #444; } .card .panel-body { font-size: 60px; } .card .fa-cart-plus { font-size: 26px; } .cart { margin: 60px 100px; } .cart i { font-size: 46px; color: #aeb733; } .card-body { position: relative; margin-bottom: 6px; } .card-body-copy { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1100; zoom: 1; } </style> <div id="cart" class="cart clearfix"> <button type="button" class="btn btn-default btn-lg pull-right"><i class="fa fa-shopping-cart" aria-hidden="true"></i></button> </div> <div class="row"> <div class="col-xs-3"> <div class="card panel panel-default"> <div class="panel-heading clearfix"> <div class="pull-right"><h4>Card 1</h4></div> </div> <div class="panel-body"> <div class="card-body"> <img src="/public/img/export_excel2.png"> 111<i style="color: #ee0;" class="fa fa-bitcoin" aria-hidden="true"></i> </div> <div class="clearfix"> <div class="pull-right"><button class="btn"><i class="fa fa-cart-plus" aria-hidden="true"></i></button></div> </div> </div> </div> </div> <div class="col-xs-3"> <div class="card panel panel-default"> <div class="panel-heading clearfix"> <div class="pull-right"><h4>Card 2</h4></div> </div> <div class="panel-body"> <div class="card-body"> <img src="/public/img/export_excel2.png"> 222<i style="color: #c0e;" class="fa fa-eur" aria-hidden="true"></i> </div> <div class="clearfix"> <div class="pull-right"><button class="btn"><i class="fa fa-cart-plus" aria-hidden="true"></i></button></div> </div> </div> </div> </div> <div class="col-xs-3"> <div class="card panel panel-default"> <div class="panel-heading clearfix"> <div class="pull-right"><h4>Card 3</h4></div> </div> <div class="panel-body"> <div class="card-body"> <img src="/public/img/export_excel2.png"> 333<i style="color: #0f0;" class="fa fa-dollar" aria-hidden="true"></i> </div> <div class="clearfix"> <div class="pull-right"><button class="btn"><i class="fa fa-cart-plus" aria-hidden="true"></i></button></div> </div> </div> </div> </div> </div> <script type="text/javascript"> (function($) { $('body').on('click', '.btn', function(e) { var card = $(this).closest('.panel-body').find('.card-body'); if (!card.size()) return; var cardCopy = $(this).closest('.panel-body').find('.card-body-copy'); if (!cardCopy.size()) $('<div/>').addClass('card-body-copy').html( card.html() ).appendTo(card); var cart = $('#cart .fa-shopping-cart'); var cardCopy = $(this).closest('.panel-body').find('.card-body-copy'); if (!cardCopy.size() || !cart.size()) return; var cardCoord = cardCopy.offset(); var cartCoord = cart.offset(); var lenght = Math.sqrt(Math.pow((cardCoord.top-cartCoord.top),2) + Math.pow((cardCoord.left-cartCoord.left),2)); var zoom = 0.5; var speed = 1/4; // pixel in msec var maybeTransform = (card.get(0).style['transform'] !== undefined); var l = (cartCoord.left - cardCoord.left); var t = (cartCoord.top - cardCoord.top); $(cardCopy).animate($.extend({ opacity: 0.4, left: (l > 0 ? '+' : '') + l.toString(), top: (t > 0 ? '+' : '') + t.toString(), }, maybeTransform ? {} : {height: 'toggle'}), { duration: lenght/speed, step:function(now, fx){ if (fx.prop == 'left') { $(this).css('transform-origin', '0 0' ); $(this).css('transform', 'scale('+ (zoom+(1-zoom)*(1-now/l)) +')' ); } }, complete:function(){ $(this).remove(); // cart.toggle( "bounce", { times: 3 }, "slow" ); } }); }); })(jQuery); </script>

JavaScript генератор паролей

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

IE and cursor and pseudo element, detect click event on pseudo-element

Отображаем курсор над псевдо элементом, как обычно у всех, все хорошо, в ИЕ не работает.
IE :-(
IE :-)
Для ИЕ используем пустой спан как контейнер.
<div id="test1" class="test">IE -(</div>
<div id="test2" class="test">IE -)<span></span></div>
И уже на него
#test2 span {
 cursor: pointer;
}
Ну и тут же тема, как ловить события от псевдоэлемента, имеем три варианта:
1) воспользуемся CSS свойством pointer-events, но тут ограничение, поддержка только в современных браузерах, так и сделано для первого примера
2) обернуть реальным элементом, тема для второго варианта
3) еще можно контролировать координаты точки клика, как-то так
if (e.offsetX > e.target.clientWidth) {

text-overflow: ellipsis и иже сним

Забью свой костыль в виде аналога эффекта получаемого с помощью свойства text-overflow: ellipsis.
В моем случае задача немного сложнее, текст не умещался совсем не много, не больше чем на 50%, потому нужно было его отобразить при наведении курсора.
Все очень легко, на помощь пришли, псевдокласс :hover и свойство direction: rtl. Все бы ничего, Firefox, Chrome, Opera, все отлично, IE приплыли.
Безымянный Ну раз уж, придется задействовать JavaScript, значит чуточку добавим требований, текст не будет просто переключаться с отображения, то с начала, то с конца, пусть он плавно прокручивается, тут и ограничение в 50% снимется.
Сляпал плагин, малютка, меньше сотни строк.

А как работает твой браузер?
Раз, два, три, четыре, пять, вышел зайчик погулять, заинька вышел… Вдруг охотник выбегает, прямо в зайчика стреляет, пиф-паф, ой-ой-ой, умирает зайчик мой, привезли его домой, оказался он живой
Живой пример
Раз, два, три, четыре, пять, вышел зайчик погулять, заинька вышел… Вдруг охотник выбегает, прямо в зайчика стреляет, пиф-паф, ой-ой-ой, умирает зайчик мой, привезли его домой, оказался он живой
Есть нюанс в зависимости от шрифта могут «уехать» точки, нужно добавить некую поправку, но пока править не стал, в моем случае все и так работало.

HTML5 data-* attributes and jQuery.data() in examples

HTML5 data-* attributes and jQuery.data() в примерах:
1) загруженная страница содержит

<input id="test" type="text" data-data1="qwer" data-data2-test="asdf" value="test">


2) на Vanilla JS
document.getElementById("test").dataset.data1
"qwer"
document.getElementById("test").dataset.data2-test
NaN
document.getElementById("test").dataset.data2Test
"asdf"

-- для IE 10- dataset не доступен

>> document.getElementById("test").dataset.data1 
"Не удалось получить свойство "data1" ссылки, значение которой не определено или является NULL" 
>> document.getElementById("test").getAttribute("data-data1") 
"qwer" 
>> document.getElementById("test").getAttribute("data-data2-test") 
"asdf" 
используем jQuery для чтения, все универсально, и главное, в IE тоже работает как надо
$('#test').data('data1')
"qwer"
$('#test').data('data2-test')
"asdf"
$('#test').data('data2Test')
"asdf"
а сейчас попробуем сохранить данные используя jQuery.data(), и более того сохраним объект содержащий функцию
видим результат, с помощью jQuery можем легко воспользоваться сохраненным объектом
но эти данные не появляются в dataset, они сохраняются в кэше jQuery, при первой же попытке получить значение data элемента, jQuery создаст свойство в DOM структуре этого элемента «jQuery + $.now()» и присвоит ему уникальный идентификатор (или ранее), который и будет использоваться как ключ для доступа к кэшу, а так же, скопирует атрибуты data в свой кэш.
$('#test').data('data3', {tt: 10, fn1: function(n){ return this.tt * n + n } })

$('#test').data('data3').fn1(5)
55
document.getElementById("test").dataset.data3
undefined
jQuery1113024019371896630481: 63

jQuery.cache[63]
Object {data: Object, parsedAttrs: true}
jQuery.cache[63].data
Object {data2Test: "asdf", data1: "qwer", data3: Object}
$('#test').data()
Object {data2Test: "asdf", data1: "qwer"}
$.cache[63].data
Object {data2Test: "asdf", data1: "qwer"}
$('#test').attr('data-data1', 'qazwsx')
[<input id=​"test" type=​"text" data-data1=​"qazwsx" data-data2-test=​"asdf" value=​"test">​]
$('#test').data()
Object {data2Test: "asdf", data1: "qwer"}
$('#test')[0].dataset
DOMStringMap {data1: "qazwsx", data2Test: "asdf"}
$('#test').attr('data-data3', 'qazwsx')
[<input id=​"test" type=​"text" data-data1=​"qazwsx" data-data2-test=​"asdf" value=​"test" data-data3=​"qazwsx">​]
$('#test')[0].dataset
DOMStringMap {data1: "qazwsx", data2Test: "asdf", data3: "qazwsx"}
$('#test').data()
Object {data2Test: "asdf", data1: "qwer"}
$('#test').data('data1', 123)
[<input id=​"test" type=​"text" data-data1=​"qazwsx" data-data2-test=​"asdf" value=​"test" data-data3=​"qazwsx">​]
$('#test').data()
Object {data2Test: "asdf", data1: 123}
$('#test')[0].dataset
DOMStringMap {data1: "qazwsx", data2Test: "asdf", data3: "qazwsx"}
$('#test')[0].dataset.data1 = 777
777
$('#test')[0].dataset
DOMStringMap {data1: "777", data2Test: "asdf", data3: "qazwsx"}
$('#test').data()
Object {data2Test: "asdf", data1: 123}
Итоги:
  1. data атрибуты загружаются в свойство attributes элемента
  2. если браузер поддерживает DOMStringMap, то в свойство dataset дублируются все атрибуты data? имена приводятся в верблюжью нотацию, если в имени присутствуют «-«
  3. jQuery сохраняет копию data атрибутов и более их не обновляет, ни в одну из сторон, jQuery использует javascript для хранения данных, а не DOM, вот тут то плагины jQuery и хранят свои экземпляры