Русский
Русский
English
Статистика
Реклама

Google api

Обновляемся на новую версию API Android по наставлению Google

27.05.2021 20:23:24 | Автор: admin

Скоро выходит Android 12, но в этом августе уже с 11-й версии разработчикам придётся использовать новые стандарты доступа приложений к внешним файлам. Если раньше можно было просто поставить флаг, что ваше приложение не поддерживает нововведения, то скоро они станут обязательными для всех. Главный фокус повышение безопасности.

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

Что происходит

Если вы уже знакомы с теорией, то этот раздел можно пропустить тут я хочу поверхностно сравнить подходы к предмету в разных версиях операционной системы.

В Android есть внутреннее Internal Storage (IS) и внешнее хранилище External Storage (ES). Исторически это были встроенная память в телефоне и внешняя SD-карта, поэтому ES был больше, но медленнее и дешевле. Отсюда и разделение настройки и критически важное записывали в IS, а в ES хранили данные и большие файлы, например, медиа. Потом ES тоже стал встраиваться в телефон, но разделение, по крайней мере логическое, осталось.

У приложения всегда есть доступ к IS, и там оно может делать что угодно. Но эта папка только для конкретного приложения и она ограничена в памяти. К ES нужно было получать доступ и, кроме манипуляции со своими данными, можно было получить доступ к данным других приложений и производить с ними любые действия (редактировать, удалять или украсть).

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

В Android решили всё это переделать ещё в 10-й версии, а в 11-й это стало обязательным.

Чтобы минимизировать риски для пользователя в Google решили внедрить Scoped Storage (SS) в ES. Возможность проникнуть в папки других приложений убрали, а доступ есть только к своим данным теперь это сугубо личная папка. А IS с 10-й версии ещё и зашифрована по умолчанию.

В Android 11 Google зафорсировала использование SS когда таргет-версия SDK повышается до 30-й версии API, то нужно использовать SS, иначе будут ошибки, связанные с доступом к файлам. Фишка Android в том, что можно заявить совместимость с определённой версией ОС. Те, кто не переходили на 11, просто говорили, что пока не совместимы с этой версий, но теперь нужно начать поддерживать нововведения всем. С осени не получится заливать апдейты, если не поддерживаешь Android 11, а с августа нельзя будет заливать новые приложения.

Если SS не поддерживается (обычно это для девайсов ниже 10-й версии), то для доступа к данным других приложений требуется получить доступ к чтению и записи в память. Иначе придётся получать доступ к файлам через Media Content, Storage Access Framework или новый, появившийся в 11-м Android, фреймворк Datasets в зависимости от типа данных. Здесь тоже придётся получать разрешение доступа к файлу, но по более интересной схеме. Когда расшариваемый файл создаёшь сам, то доступ к нему не нужен. Но если переустановить приложение доступ к нему опять потребуется. К каждому файлу система привязывает приложение, поэтому когда запрашиваешь доступ, его может не оказаться. Особо беспокоиться не нужно, это сложно отследить, поэтому лучше просто сразу запрашивать пермишен.

Media Content, SAF и Datasets относятся к Shared Storage (ShS). При удалении приложения расшаренные данные не удаляются. Это полезно, если не хочется потерять нужный контент.

Хотя даже при наличии SS можно дать доступ к своим файлам по определённой технологии через FileProvider можно указать возможность получения доступа к своим файлам из другого приложения. Это нормально, потому что файлы расшаривает сам разработчик.

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

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

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

Перейдём к практике.

Переход на новую версию

Основная функциональность по работе с файлами в приложении iFunny представлена в виде сохранения мемов в память и расшаривания их между приложениями. Это было первое, что требовалось починить.

Для этого выделили в общий интерфейс работу с файлами, реализация которого зависела от версии API.

interface FilesManipulator {    fun createVideoFile(fileName: String, copy: Copier): Uri    fun createImageFile(fileName: String, copy: Copier): Uri    fun createFile(fileName: String, copy: Copier): Uri    fun getPath(uri: Uri): String    fun deleteFile(uri: Uri)}

FilesManipulator представляет собой интерфейс, который знает, как работать с файлами и предоставляет разработчику API для записи информации в файл. Copier это интерфейс, который разработчик должен реализовать, и в который передаётся поток вывода. Грубо говоря, мы не заботимся о том, как создаются файлы, мы работаем только с потоком вывода. Под капотом до 10-й версии Android в FilesManipulator происходит работа с File API, после 10-й (и включая её) MediaStore API.

Рассмотрим на примере сохранения картинки.

fun getContentValuesForImageCreating(fileName: String): ContentValues {    return ContentValues().apply {        put(MediaStore.Images.Media.DISPLAY_NAME, fileName)        put(MediaStore.Images.Media.IS_PENDING, FILE_WRITING_IN_PENDING)        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + appFolderName)    }}fun createImageFile(fileName: String, copy: Copier): Uri {    val contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)    val contentValues = getContentValuesForImageCreating(fileName)    val uri = contentResolver.insert(contentUri, contentValues)         ?: throw IllegalStateException("New image file insert error")    downloadContent(uri, copy)    return uri}fun downloadContent(uri: Uri, copy: Copier) {    try {        contentResolver.openFileDescriptor(uri, FILE_WRITE_MODE)                .use { pfd ->                    if (pfd == null) {                        throw IllegalStateException("Got nullable file descriptor")                    }                    copy.copyTo(FileOutputStream(pfd.fileDescriptor))                }        contentResolver.update(uri, getWriteDoneContentValues(), null, null)    } catch (e: Throwable) {        deleteFile(uri)        throw e    }}fun getWriteDoneContentValues(): ContentValues {    return ContentValues().apply {        put(MediaStore.Images.Media.IS_PENDING, FILE_WRITING_DONE)    }}

Так как операция сохранения медиафайлов достаточно длительная, то целесообразно использовать MediaStore.Images.Media.IS_PENDING, которая при установлении значения 0 не дает видеть файл приложениям, отличного от текущего.

По сути, вся работа с файлами реализована через эти классы. Шаринг в другие приложения автоматически сохраняют медиа в память устройства и последующая работа с URI уже происходит по новому пути. Но есть такие SDK, которые ещё не успели перестроиться под новые реалии и до сих пор используют File API для проверки медиа. В этом случае используем кеш из External Storage и при необходимости провайдим доступ к файлу через FileProvider API.

Помимо ограничений с памятью в приложениях, таргетированных на 30-ю версию API, появилось ограничение на видимость приложения. Так как iFunny использует шаринг во множество приложений, то данная функциональность была сломана полностью. К счастью, достаточно добавить в манифест query, открывающую область видимости к приложению, и можно будет также полноценно использовать SDK.

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

<manifest  ><queries><intent>    <action android:name="android.intent.action.SENDTO" />    <data android:scheme="smsto, mailto" /></intent>    <package android:name="com.twitter.android" />    <package android:name="com.snapchat.android" />    <package android:name="com.whatsapp" />    <package android:name="com.facebook.katana" />    <package android:name="com.instagram.android" />    <package android:name="com.facebook.orca" />    <package android:name="com.discord" />    <package android:name="com.linkedin.android" /></queries></manifest>

После проверок запуска UI-тестов на девайсах с версиями API 29-30 было выявлено, что они также перестали корректно отрабатываться.

Первоначально в LogCat обнаружил, что приложение не может приконнектиться к процессу Orchestrator и выдает ошибку java.lang.RuntimeException: Cannot connect to androidx.test.orchestrator.OrchestratorService.

Эта проблема из разряда видимости других приложений, поэтому достаточно было добавить строку <package android:name="androidx.test.orchestrator" /> .

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

Очевидно из-за Scoped Storage стало невозможно сохранять файлы в другие папки, поэтому снова почитав документацию по управлению файлами в памяти на девайсе, обнаружил интересный раздел. Там рассказано, как для нужд тестов открыть доступ к папкам девайса, но с существенными ограничениями, которые можно почитать тут.

Так как нам нужно использовать этот пермишен только для тестов, то нам условия подходят. Поэтому я быстренько написал свой ShellCommandExecutor, который выполняет команду adb shell appops set --uid PACKAGE_NAME MANAGE_EXTERNAL_STORAGE allow на создании раннера тестов.

На Android 11 тесты удачно запустились и стали проходить без ошибок.

После попытки запуска на 10-й версии Android обнаружил, что отчет Allure также перестал сохраняться в память девайса. Посмотрев issue Allure, обнаружил, что проблема известная, как и с 11-й версией. Достаточно выполнить команду adb shell appops set --uid PACKAGE_NAME LEGACY_STORAGE allow. Сказано, сделано.

Запустил тесты всё еще не происходит сохранения в память отчёта. Тогда я обнаружил, что в манифесте WRITE_EXTERNAL_STORAGE ограничен верхней планкой до 28 версии API, то есть запрашивая работу памятью мы не предоставили все разрешения. После изменения верхней планки (конечно, для варианта debug) и запроса пермишена на запись тесты удачно запустились и отчёт Allure сохранился в память устройства.

Добавлены следующие определения пермишенов для debug-сборки.

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /><uses-permission    android:name="android.permission.WRITE_EXTERNAL_STORAGE"    android:maxSdkVersion="29"    tools:node="replace" />

После всех вышеописанных манипуляций с приложением, можно спокойно устанавливать targetSdkVersion 30, загружать в Google Play и не беспокоиться про дедлайн, после которого загружать приложения версией ниже станет невозможно.

Подробнее..

Google документы станут полновесными с 1 июня. Пишем скрипт для обхода этого ограничения

27.05.2021 18:04:31 | Автор: admin

Предыстория

Google изменяет политику хранения данных с 1 июня 2021 года. Вкратце: документы и фото теперь станут полновесными и будут учитываться в общей квоте 15Гб. К тому же, при неактивности аккаунта более двух лет, Google может удалить ваши данные.

Я часто работаю с Google документами, и при активном использовании дисковая квота закончится довольно быстро. Но есть и хорошая новость: документы, созданные до 1 июня 2021 года так и останутся невесомыми, поэтому вы не получите превышение квоты в одночасье.

У меня сразу возникла мысль сделать документов "в запас". Ниже я расскажу, как это можно осуществить, не тратя много времени и сил.

Пишем скрипт, создающий документы

Скрипт буду писать с помощью Google Apps Script.

Создаём новую таблицу Google, заходим в редактор скриптов (Инструменты - Редактор скриптов).

Создаём три файла

  • main.gs - основной код для создания файлов

  • menu.gs - код для создания пользовательского меню при открытии таблицы

  • index.html - шаблон страницы для отображения информации

Сначала в файле menu.gs создаём функцию onOpen(). Это простой триггер, который выполняется при открытии таблицы. Его задача - создать пользовательское меню, из которого можно запустить функцию.

function onOpen(e) { // Выполняется при открытии таблицы  SpreadsheetApp.getUi() // Получаем интерфейс пользователя      .createMenu('Меню')// Создаём меню      .addItem('Создать документы', 'main') // Создаём команду меню      .addToUi();// Добавляем меню в интерфейс}

В файле main.gs создадим функцию main(), которая и будет запускаться с кнопки в меню.

function main() { // Меню - Создать документы  // Создаём HTML документ из шаблона  let template = HtmlService.createTemplateFromFile(`index`);  // Показываем модальное окно с HTML   SpreadsheetApp.getUi()    .showModelessDialog(template.evaluate(),`Создаю документы...`);}

Пора разобраться с index.html. это обычный HTML файл, который отобразится в модальном окне. там можно использовать стили, скрипты и т.п.

index.html
<!doctype html><html lang="en">  <head>    <!-- Required meta tags -->    <meta charset="utf-8">    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">    <!-- Bootstrap CSS -->    <link rel="stylesheet" href="http://personeltest.ru/aways/maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">    <script>      let timer = function(){ // Обслуживает таймер        // Получаем текущее время        let now = (new Date()).getTime();                // Задаём режим обновления таймера - 1 раз в секунду        setInterval(function(){          // Получаем прошедшее время. now будет доступно из-за замыкания.          let time = (new Date()).getTime() - now;          // Вычисляем минуты          let minutes = Math.floor(time/60000);          // И секунды          let seconds = Math.floor(time%60000/1000);          // Обновляем данные в поле lifeTime          updateData("lifeTime", `${minutes<10?"0"+minutes:minutes}:${seconds<10?"0"+seconds:seconds}`);        },1000);      };            updateData = function(id, value){ // Обновляет информацию в одном элементе по id        let element = document.getElementById(id);        if (element) {          element.innerHTML = value;        };      };            refreshData = function(){ // Обновляем все данные        google.script.run.withSuccessHandler(function(data){        updateData("createTables", data.createTables); // Таблиц создано:        updateData("createDocs", data.createDocs);// Документов создано:        updateData("createForms", data.createForms);// Форм создано        updateData("createSlides", data.createSlides);// Презентаций создано        updateData("log", data.log);// Лог      }).getData(); // Получаем данные     };      setTimeout(setInterval(refreshData, 2000),1000); // Данные обновляем раз в 2 секунды      timer();// Запуск таймера      google.script.run.doMagic();// Запуск функции для создания документов    </script>  </head>  <body>    <div class=".container bg-dark text-white text-center">      <div class="row">        <div class="col">          Таблиц        </div>                <div class="col">          Документов        </div>                <div class="col">          Форм        </div>                <div class="col">          Презентаций        </div>      </div>      <div class="row">        <div class="col" id="createTables">          0        </div>        <div class="col" id="createDocs">          0        </div>                <div class="col" id="createForms">          0        </div>                <div class="col" id="createSlides">          0        </div>      </div>    </div>         <div class=".container bg-dark text-white">      <div class="row">        <div class="col text-right" id="label_lifeTime">          Время работы:          </div>                <div class="col  text-left" id="lifeTime">          00:00        </div>              </div>    </div>    <div bg-dark text-white id="label_log">Лог: </div>    <ul class="list-group" id="log">    </ul>  </body></html>

В ней подключаем стили, создаём скрипт, в котором три функции:

  • updateData(id, value) - ищет на странице элемент по id и обновляет содержимое

  • refreshData() - обновляет все данные на странице, кроме таймера

  • timer() - обслуживает таймер

Для создания документов в файле main.gs создаём универсальную функцию.

function create()
const FILES_TO_CREATE = 50;function create(filesToCreate = FILES_TO_CREATE, folderId, prefix="file_", app, key) {  // Получаем словарь ключ-значение для текущего скрипта.  let props = PropertiesService.getScriptProperties();  // Получаем значение для ключа data. Преобразуем в объект  let data = JSON.parse(props.getProperty(`data`));  try{    // Получаем директорию по id    let folder = DriveApp.getFolderById(folderId);    for(var i=0; i<filesToCreate; i++){ // Создаём filesToCreate документов      // Создаём документ, получаем его id      let ssId = app.create(`${prefix}${(new Date()).getTime()}`).getId();      // Получаем файл по его id      let ss = DriveApp.getFileById(ssId);      // Копируем файл в папку      folder.addFile(ss);      // Удаляем файл из корневой папки      DriveApp.getRootFolder().removeFile(ss);      // Увеличиваем счётчик созданных файлов      data[key]=1+data[key];      // Сохраняем новые данные      props.setProperty(`data`, JSON.stringify(data));    };  }catch(err){    // В случае ошибки - пишем её в лог    logToHtml(`Error: ${err}`, LOG_TYPES.danger);  };  // Возвращаем из функции количество созданных файлов  return +i;};

И конкретные реализации - для таблиц, документов, форм и презентаций.

Обёртки для функции create()
function createSheets(key) {  // Получаем id папки для таблиц  let folderId =  PropertiesService.getScriptProperties().getProperty(`sheetsFolder`);  // Создаём нужное количество таблиц  let count = create(FILES_TO_CREATE, folderId, `sheet_`, SpreadsheetApp, key);  // Сохраняем в лог  logToHtml(`${count} sheets were created`, LOG_TYPES.success);}function createDocs(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`docsFolder`);  let count = create(FILES_TO_CREATE, folderId, `doc_`, DocumentApp, key);  logToHtml(`${count} docs were created`, LOG_TYPES.success);}function createForms(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`formsFolder`);  let count = create(FILES_TO_CREATE, folderId, `form_`, FormApp, key);  logToHtml(`${count} forms were created`, LOG_TYPES.success);}function createSlides(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`slidesFolder`);  let count = create(FILES_TO_CREATE, folderId, `slide_`, SlidesApp, key);  logToHtml(`${count} slides were created`, LOG_TYPES.success);}

Далее делаем функцию для создания папок.

function createFolders(){  // Получаем словарь ключ-значение для текущего скрипта  let props = PropertiesService.getScriptProperties();  // Задаём структуру папок  let folders = [    {key:`rootFolder`,   name:`Прозапас`                         },    {key:`sheetsFolder`, name:`Sheets`, parentFolder:`rootFolder`},    {key:`docsFolder`,   name:`Docs`,   parentFolder:`rootFolder`},    {key:`formsFolder`,  name:`Forms`,  parentFolder:`rootFolder`},    {key:`slidesFolder`, name:`Slides`, parentFolder:`rootFolder`},    ];  // Проходим по структуре и создаём папки    folders.forEach(folder=>{      if (!props.getProperty(folder.key)){ // Если папка ещё не создана        // Если есть параметр rootFolder, то используем его, иначе выбираем корневую папку        let parentFolder = folder.parentFolder?DriveApp.getFolderById(props.getProperty(folder.parentFolder)):DriveApp.getRootFolder();        // Создаём папку        let folderId = parentFolder.createFolder(folder.name).getId();        // Сохраняем информацию о ней        props.setProperty(folder.key, folderId);      };    });}

И функцию для создания всех типов документов:

Основная функция, которая запускает создание папок и документов
function doMagic(){  let props = PropertiesService.getScriptProperties();  // Структура данных  let data = {    createTables:0,     createDocs:0,     createForms:0,     createSlides:0,     startTime:new Date(),    log:``,  };  // Сохраняем данные в словарь скрипта  props.setProperty(`data`, JSON.stringify(data));    try{    createFolders(); // Создаём папки    createSheets(`createTables`);// Создаём таблицы    createDocs(`createDocs`);// Создаём документы    createForms(`createForms`);// Создаём формы    createSlides(`createSlides`);// Создаём презентации        // Сообщаем, что всё готово    SpreadsheetApp.getUi().alert(`Готово!`);  }catch(err){    // При ошибке сообщаем об этом    SpreadsheetApp.getUi().alert(`Ошибка! ${err}`);  };};

Остаётся создать функцию для получения данных - так модальное окно может получить актуальные данные для отображения.

function getData(){ // Получает данные из словаря  // Получаем словарь ключ-значение  let props = PropertiesService.getScriptProperties();  // Получаем нужные данные и преобразуем в объект  let data = JSON.parse(props.getProperty(`data`));  return data; //Возвращаем данные};

И функция для записи строки в лог:

const LOG_TYPES = { // Типы сообщений. Взято из bootstrap  primary:   "primary",  secondary: "secondary",  success:   "success",  danger:    "danger",  warning:   "warning",  info:      "info",  };function logToHtml(log, type = LOG_TYPES.primary){  // Получаем данные  let data = getData();  // Добавляем li тег (лог отображается в виде списка) с данными  data.log+=`<li class="list-group-item text-${type}">${log}</li>\n`;  // Сохраняем данные  let props = PropertiesService.getScriptProperties();  props.setProperty(`data`, JSON.stringify(data));};

Что получилось

Остаётся только запустить скрипт из меню. При первом запуске Google запросит права - это нормально. После этого откроется окно, в котором можно наблюдать за прогрессом.

Модальное окно для визуализации прогрессаМодальное окно для визуализации прогресса

Ограничения скрипта

  • Время жизни скрипта ограничено 6 минутами. За это время он успеет создать несколько сотен документов. Можно обойти это ограничение, закинув все функции непосредственно в HTML код модального окна и обращаться к диску по API, но об этом в следующий раз

  • Есть ограничение на количество ежедневно созданных документов. Рано или поздно будет появляться ошибка Exception: Служба была вызвана слишком много раз за день: docs create. Тогда скрипт можно запустить на следующий день


TL;DR

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

Спасибо за внимание. Буду рад получить фидбэк по коду. Удачи!

Подробнее..

Как стать экспертом для поисковых систем

17.08.2020 06:18:36 | Автор: admin
Не так давно я познакомился с описанием E-A-T алгоритма от Google, который расшифровывается как Expertise, Authoritativeness, Trustworthiness (экспертность, авторитетность, достоверность). И мне, как автору, который пишет для разных сайтов стало интересно насколько я сам соответствую критериям этого алгоритма и могу ли повлиять на текущую ситуацию. Тем более, что некоторые заготовки в виде открытой гугл таблицы для учета и мониторинга собственных публикаций LynxReport уже были.


Google Таблицы Node.js Google Charts Сайт-визитка Топ-3 место в поиске ФИО + специализация

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

  1. Актуальную сводку публикаций, расположенную на временной шкале Google Charts.
  2. Автоматическую генерацию выходных данных и ссылок на статьи из гугл таблицы в html версию визитки.
  3. PDF версии статей со всех сайтов, из-за опасений закрытия некоторых старых сайтов в будущем.

Как получилось можно посмотреть здесь. Реализовано на платформе Node.js с использованием Bootstrap, Google Charts и Google Таблицы для хранения исходных данных.

Исходные данные о публикациях в Google Spreadsheet


Гугл-таблица LynxReport: учёт публикаций содержит все исходные данные и аналитику по публикациям. Я поддерживаю актуальность сведений на вкладке Данные, вручную вписывая новые ссылки на статьи, остальное скачивается по большей части автоматически.


Часть таблицы LynxReport: учёт публикаций с исходными данными

Актуальные данные по просмотрам и комментариям подгружаются через формулы.

Например, чтобы получить количество просмотров со страниц Хабра в ячейке гугл таблиц используется формула:

=IF(ISNUMBER(IMPORTXML(D6, "//*[@class='post-stats__views-count']")),SUBSTITUTE(IMPORTXML(D6, "//*[@class='post-stats__views-count']"),",","."),value(SUBSTITUTE(SUBSTITUTE(IMPORTXML(D6, "//*[@class='post-stats__views-count']"),"k",""),",","."))*1000)

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


Часть таблицы LynxReport: учёт публикаций с аналитикой

Считывание данных из Таблицы и преобразование в формат Google Charts


Чтобы трансформировать эти сводные данные из гугл таблицы в сайт-визитку мне надо было преобразовать данные в формат временной шкалы Google Charts.


Получившаяся временная шкала Google Charts на сайте-визитке

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


Данные для Google Charts на сайте-визитке в html виде

Чтобы выполнять все преобразования автоматически я написал под Node.js скрипт, который доступен на GitHub.

Если вы не знакомы с Node.js, то в своей предыдущей статье я подробно расписал как можно воспользоваться скриптом под разными системами:

  1. Windows
  2. macOS
  3. Linux

Ссылка с инструкциями здесь. Принцип аналогичен.


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

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

Получить этот ключ можно в консоли управления проектами гугла:


Учетные данные в Google Cloud Platform

После завершения работы скрипта должны сгенерироваться два текстовых файла с html данными графиков и все pdf копии онлайн статей.

Данные из текстовых файлов я импортирую в html код сайта-визитки.

Генерация pdf копий статей с сайтов


При помощи Puppeteer сохраняю текущий вид статей вместе со всеми комментариями в pdf виде.

Если не ставить задержку, то несколько десятков статей по списку можно сохранить в виде pdf файлов всего за несколько минут.

А задержка нужна для того чтобы на некоторых сайтах (например на ТЖ) успели подгрузиться комментарии.

Результаты


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

Поиск по имени и фамилии + указание специализации в обоих случаях возвращает ссылки на мои статьи и даже сайт-визитку:

В выдаче Яндекса:


В выдаче Гугла:


Пока что не могу решить стоит ли регистрировать отдельное доменное имя, если визитка empenoso.github.io и так находится на верхних строчках поиска?

Вместо заключения


  1. Возможно, эта статья заставит кого-то задуматься о том, как он выглядит в интернете.
  2. Возможно, эта статья поможет кому-то наладить учёт и организацию публикаций.
  3. Исходный код скрипта расположен на GitHub.


Автор: Михаил Шардин

17 августа 2020 г.
Подробнее..

Экстренная психологическая помощь Prototyping Weekend

05.09.2020 16:05:22 | Автор: admin

#openDevelopment #codeSaveLives
Привет Хабр! Я завершил работу над прототипом платформы, которая объединяет психологов-добровольцев и людей, нуждающихся в экстренной помощи. Это инициатива в ответ на насилие, происходящее в настоящее время в Беларуси и Ливане:
https://brmlab.cz/project/belhack/start

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

Это может быть не только психологическая помощь, но и онлайн-инструкции о том, как поступить с пострадавшим. Пользователь этого приложения - человек, которому немедленно нужна неотложная помощь, и мед-волонтер, кто готов помощь оказать. Это расширение концепции Online Doctor на экстренную помощь.

Презентация механики:

*** Технические подробности ***

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

#googleMeet #googleSpreadSheet #googleAppsScript #googleChromeExtension

Для создания прототипа я использовал технологии и сервисы Google: электронную таблицу, встречу, расширение, apps script.

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

Как пациент: Нажав на кнопку, я присоединюсь к встрече, после чего смогу получить поддержку от свободного в данную минуту специалиста - волонтера.

Как психолог: Я готов начать прием людей, нажимаю кнопку Ready to help. Мне нужно нажать кнопку Help in Progress (используя расширения), когда кто-то присоединяется, и статус в таблице этого доктора меняется на 1. Затем строка с врачом исчезает со страницы.

Общение происходит в среде Google Meet.

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

Подробнее..

Core Web Vitals как Google решил оценивать сайты

02.03.2021 12:21:43 | Автор: admin


Всем привет!

Сегодня поговорим о важности пользовательского взаимодействия, ведь совсем скоро придется подготовить свои сайты к максимальному ускорению загрузки. Возможно, вы уже слышали про Core Web Vitals

В прошлом году Google начал масштабный пересмотр факторов ранжирования в поисковике, чтобы улучшить качество поисковой выдачи. И в ноябре команда Google анонсировала Core Web Vitals новые факторы оценки качества ресурсов, которые смогут влиять на индексацию и вступят в силу в мае 2021 года. Давайте разбираться.


Существуют сотни факторов ранжирования, однако Core Web Vitals будет анализировать ещё больше информации и иметь непосредственное влияние на дальнейшую индексацию. Нужно отметить, что скорость загрузки напрямую не влияет на индексацию, однако имеет значительное влияние на поведение пользователя, которое является важным сигналом для поисковых алгоритмов Google.

Чем Core Web Vitals отличается от прочих факторов ранжирования?
Положительная сторона Core Web Vitals в прозрачности: это понятные, публично доступные критерии, которые можно отслеживать и улучшать с помощью специального набора инструментов. Кроме того, с момента анонсирования и до официального запуска есть много времени, чтобы уже начать работать над Core Web Vitals.

Андрей Липатцев, Web Partnerships Google

Исследования показали, что 47% пользователей ожидают загрузки страницы до 2 секунд. Согласно отчету Google, если это время увеличивается с 1 до 3 секунд, количество отказов возрастает на 32%. А при увеличении с 1 до 6 секунд на целых 106%.
Если ресурс будет отвечать пороговым значениям Core Web Vitals, покидать сайт будут на целых 24% пользователей меньше.

Core Web Vitals



Среди многих показателей ранжирования (оптимизации для мобильных устройств, безопасный просмотр, безопасность HTTPS и т.д.) Google выделил основные (core), жизненно важные для пользователя. Метрики, составляющие Core Web Vitals, со временем будут развиваться и дополняться.

Текущий набор показателей фокусируется на трех аспектах взаимодействия с пользователем:


  • Largest Contentful Paint (LCP) определяет скорость загрузки страницы и ее крупных визуальных элементов. Хороший показатель до 2,5 с.
  • First Input Delay (FID) измеряет интерактивность сайта, то есть насколько быстро он становится доступным к взаимодействию после загрузки. Желательным будет показатель до 100 мс.
  • Cumulative Layout Shift (CLS) показывает скорость визуальной стабилизации, то есть насколько быстро всё становится на свои места. Идеальным будет показатель меньше 0,1.

Давайте разберем каждый показатель подробнее для более глубокого понимания. Или можете перейти сразу к пункту Как улучшить показатели Core Web Vitals

LCP (загрузка)


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

Старые метрики, такие как load или DOMContentLoaded, не подходят, так как они всегда соответствуют тому, что пользователь видит на экране. А более новые показатели производительности, такие как First Contentful Paint (FCP), отражают только самое начало процесса загрузки.
В ходе исследований обнаружилось, что более точный способ измерить загрузку основного содержимого страницы, это посмотреть, когда был отрисован самый большой элемент.

Так появилась метрика Largest Contentful Paint (LCP), которая измеряет время рендеринга самого большого элемента на странице.


Что считается большим элементом?


  • тег img
  • элементы image внутри тега svg
  • постер в теге video
  • фоновое изображение, загруженное с помощью url() (не считая CSS градиента)
  • блочные элементы, содержащие текстовые узлы или другие дочерние элементы.

Пока рассматривается ограниченный список, чтобы упростить начальное внедрение Core Web Vitals. Дополнительные элементы (например, тег svg, video) планируют добавить в будущем по мере проведения дополнительных исследований.

Как это работает?


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

Чтобы справиться с таким изменением, браузер отрисовывает первый кадр и отправляет PerformanceEntry с параметром large-contentful-paint, который идентифицирует самый большой элемент. Но затем, после рендеринга последующих кадров, браузер будет отправлять PerformanceEntry при каждом изменении самого большого элемента.

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

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


Рис.1. Изменение самого большого элемента по мере загрузки содержимого

Как определяется размер самого большого элемента?


Размер элемента определяется в области видимости пользователя: если элемент выходит за её пределы (обрезан или имеет overflow: hidden), то эти части не учитываются.

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

Для текстовых элементов учитывается только размер их текстовых узлов.

Для всех элементов любые margin, padding или border не рассматриваются.

FID (интерактивность)


Метрика First Input Delay (FID) помогает измерить первое впечатление пользователя об интерактивности и быстродействии вашего сайта.

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

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



FID можно измерить только в реальных условиях.

Почему рассматривается именно первый ввод?


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




CLS (визуальная стабильность)


Cumulative Layout Shift важный, ориентированный на пользователя показатель для измерения стабильности верстки и элементов, не препятствующих взаимодействию с контентом.

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


Рис.2. Пример Cumulative Layout Shift

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

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


CLS измеряет общую сумму всех показателей визуальной стабильности верстки в течение сессии страницы.

Показатель визуальной стабильности определяет Layout Instability API, который отправляет layout-shift каждый раз, когда существующий элемент меняет свое начальное положение между двумя кадрами.

Обратите внимание, что визуальная стабильность не учитывается, когда новый элемент добавляется в DOM или существующий элемент меняет размер.

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

layout shift score = impact fraction * distance fraction



Рис.3. Коэффициент воздействия

На изображении выше есть элемент, который занимает половину области просмотра в одном кадре. Затем в следующем кадре элемент смещается вниз на 25% от высоты экрана. Красный пунктирный прямоугольник указывает на объединение видимой области элемента в обоих кадрах, которая в данном случае составляет 75% от экрана.


Рис.4. Доля расстояния

Доля расстояния это наибольшее расстояние, на которое любой нестабильный элемент переместился в кадре (по горизонтали или вертикали), деленное на размер экрана.
В приведенном примере наибольшим размером экрана является высота, а нестабильный элемент перемещается на 25%.

Коэффициент визуальной стабильности = 0.75 * 0.25 = 0.1875



Как улучшить показатели Core Web Vitals?
Если ваше приложение не дотягивает до идеальных показателей, то нужно заняться вопросом повышения скорости. Итак, что можно сделать:

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


Библиотеки и инструменты


Самый простой способ измерить все Core Web Vitals использовать js-библиотеку web-vitals, которая измеряет каждую метрику в соответствии с Инструментами Google.

import {getCLS, getFID, getLCP} from 'web-vitals';function sendToAnalytics(metric) {  const body = JSON.stringify(metric);  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`.  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||      fetch('/analytics', {body, method: 'POST', keepalive: true});}getCLS(sendToAnalytics);getFID(sendToAnalytics);getLCP(sendToAnalytics);

Или можно использовать расширение Web Vitals для Chrome.



  • Lighthouse позволяет проверять интерактивность, доступность, скорость загрузки страниц сайта в лабораторных условиях. C ним можно работать через командную строку, веб-интерфейс Page Speed Insights, инструменты для разработчиков в Chrome. Используйте Lighthouse после улучшений или изменений на сайте.
  • То, что видят пользователи, доступно в базе данных CrUX общедоступном наборе реальных данных о производительности пользователя. В базе находятся порядка 8-9 миллионов страниц.
  • PageSpeed Insights агрегирует данные из Lighthouse и CrUX и отображает их в отчете.
  • В Google Search Console есть данные по Core Web Vitals, и они доступны для каждой отдельной страницы и ее динамике.
  • В Chrome Dev Tools трассируются все три показателя LCP, CLS, TBT.


Рис.5. Пример отображения показателей в PageSpeed Insights

Итог


Не забывайте периодически следить за скоростью загрузки своего приложения. Быстрая реакция на любые негативные изменения позволит минимизировать потери и вовремя внести необходимые коррективы. Core Web Vitals влияет не только на индексацию, но и главным образом на конверсию, посещаемость и в результате на прибыль. К счастью, Google предупредил заранее о запуске новых факторов ранжирования, поэтому у вас есть еще время исправить все погрешности к запуску Core Web Vitals (май 2021).

Полезные ссылки и используемые материалы:

Подробнее..

Google Sheets как разноплановый помощник для непростых задач или как я делал анализатор футбольный матчей

17.04.2021 14:23:59 | Автор: admin

Лежу я ночью, пытаюсь уснуть. И как обычно тысяча мыслей, и среди них я сумел зацепился за одну. А звучала она так: "почему бы не сделать анализатора футбольных матчей, где нужно будет лишь ввести участников игры и получить выборку из их статистики общей и какие-то описание, чего ждать в грядущем матче". Действительно, почему нет?!

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

Гугл дал свой результат, впрочем как всегда. Я нашел кучу калькуляторов ставок, которые продается за 3-5к рублей, и прочие таблицы расчетов в свободном доступе. Я как бы и так помнил расчеты тоталов голов, но мне нужно было их улучшить и получить на выходе собственно целого "мага/колдуна/вангу" спортивных событий. Или хотя бы формулку, которая выдаст результат после ввода данных.

Это что, писать парсер?!

Мне не хотелось сильно углубляться в код. Во-первых, я не кодер, а скорее человек, который с ним постоянно сталкивается в работе, и совсем чуть-чуть в нем может разобраться. Во-вторых, мне просто было лень, я искал простые решения. И вспомнил, что чудо Google Sheets может парсить таблички, xml, html-страницы, и делается это прост формулами: IMPORTDATA, IMPORTFEED, IMPORTHTML, IMPORTXML. Вот ссылка на справку гугла, там все подробно описано, останавливаться на этом я пожалуй не буду.

Нашел источник футбольной статистики, что было очень сложно. Ведь мне нужно не только спарсить разок, а обновлять мои данные постоянно, поскольку футбольные матчи идут и идут, и данные нужно актуализировать. Остановился на зарубежном сборище футбольной инфы fbref.com, все в некрасивых таблицах 2002 года. "Как раз то, что мне нужно!", - вскрикнул я, после 3-его часа ресерча источника статистических данных. Ведь мне нужны были не простые, а всякие XG, XGa и прочие радости профессиональных футбольных "аналистов". Далее с помощью API Google Sheets и query запросов, по сути урезанным sql, я кидался данными из вкладки во вкладку, разбивая на те таблицы, которые мне будут нужны для расчетов.

Секунду, а как я инфу из Google Sheets на сайте смогу отобразить?!

Да, этот вопрос у меня появился после прекрасных дней ковыряния в данных и структурирования всей информации, которую я сумел спарсить. И я чутка приуныл, потому что помнил, что могу вывести айфреймом. Но, черт возьми, это так некрасиво и попахивает прошлым веком. Пришлось ковыряться дальше. Блог, куда я хотел это все засунуть, у меня стоит на обыкновенном Wordpress, но найти адекватный плагин, который выводил бы инфу в красивом виде на страницу ультра-сложно, чтобы ещё и работал нормально адекватно, конечно. В итоге, я нашел, даже с эстетикой выводимых таблиц я смирился. Взял плагин Inline Google Spreadsheet Viewer. Банальный до нельзя, но все же, мои таблички по крайней мере выглядели не совсем стыдно:

Пфф, я что не смогу найти скрипт для отправки данных в Google Sheets?

Не смогу. Потрачено кучу времени на поиск, весь стэкоферфлоу и гитхаб русскоязычный, англоязычный, все перерыл вдоль и поперёк. Думал я =). А оказалось, что я был рядом с решением моей проблемы. Проблема заключалась в следующем: нужно было дать возможность выбора футбольных команд пользователю, даже если это буду я (ибо трафика на блоге особо нет, да и я не парюсь), и при этом, отправить их в Google Sheets по API. Что оказалось не совсем легко.

Решение моей проблемы было у меня под носом. На одной из тысячи просмотренных мною страниц был заголовок "Отправка на почту через html форму, используя Google Apps". Но как и в других 999 страниц, я подумал, что это не имеет отношения ко мне, а ведь я был не прав.

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

Ниже я добавлю весь основной код. Не знаю, могу ли публиковать не свой код, но под постом оставлю естественно ссылку на источник, ибо там есть ещё прекрасная доскональная инструкция. Да и весь код закоменчен, за что автору огромное спасибо. И Google Apps Script с этим всем справился на ура.

/****************************************************************************** * This tutorial is based on the work of Martin Hawksey twitter.com/mhawksey  * * But has been simplified and cleaned up to make it more beginner friendly   * * All credit still goes to Martin and any issues/complaints/questions to me. * ******************************************************************************/// if you want to store your email server-side (hidden), uncomment the next line// var TO_ADDRESS = "example@email.net";// spit out all the keys/values from the form in HTML for email// uses an array of keys if provided or the object to determine field orderfunction formatMailBody(obj, order) {  var result = "";  if (!order) {    order = Object.keys(obj);  }    // loop over all keys in the ordered form data  for (var idx in order) {    var key = order[idx];    result += "<h4 style='text-transform: capitalize; margin-bottom: 0'>" + key + "</h4><div>" + sanitizeInput(obj[key]) + "</div>";    // for every key, concatenate an `<h4 />`/`<div />` pairing of the key name and its value,     // and append it to the `result` string created at the start.  }  return result; // once the looping is done, `result` will be one long string to put in the email body}// sanitize content from the user - trust no one // ref: https://developers.google.com/apps-script/reference/html/html-output#appendUntrusted(String)function sanitizeInput(rawInput) {   var placeholder = HtmlService.createHtmlOutput(" ");   placeholder.appendUntrusted(rawInput);     return placeholder.getContent(); }function doPost(e) {  try {    Logger.log(e); // the Google Script version of console.log see: Class Logger    record_data(e);        // shorter name for form data    var mailData = e.parameters;    // names and order of form elements (if set)    var orderParameter = e.parameters.formDataNameOrder;    var dataOrder;    if (orderParameter) {      dataOrder = JSON.parse(orderParameter);    }        // determine recepient of the email    // if you have your email uncommented above, it uses that `TO_ADDRESS`    // otherwise, it defaults to the email provided by the form's data attribute    var sendEmailTo = (typeof TO_ADDRESS !== "undefined") ? TO_ADDRESS : mailData.formGoogleSendEmail;        // send email if to address is set    if (sendEmailTo) {      MailApp.sendEmail({        to: String(sendEmailTo),        subject: "Contact form submitted",        // replyTo: String(mailData.email), // This is optional and reliant on your form actually collecting a field named `email`        htmlBody: formatMailBody(mailData, dataOrder)      });    }    return ContentService    // return json success results          .createTextOutput(            JSON.stringify({"result":"success",                            "data": JSON.stringify(e.parameters) }))          .setMimeType(ContentService.MimeType.JSON);  } catch(error) { // if error return this    Logger.log(error);    return ContentService          .createTextOutput(JSON.stringify({"result":"error", "error": error}))          .setMimeType(ContentService.MimeType.JSON);  }}/** * record_data inserts the data received from the html form submission * e is the data received from the POST */function record_data(e) {  var lock = LockService.getDocumentLock();  lock.waitLock(30000); // hold off up to 30 sec to avoid concurrent writing    try {    Logger.log(JSON.stringify(e)); // log the POST data in case we need to debug it        // select the 'responses' sheet by default    var doc = SpreadsheetApp.getActiveSpreadsheet();    var sheetName = e.parameters.formGoogleSheetName || "responses";    var sheet = doc.getSheetByName(sheetName);        var oldHeader = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];    var newHeader = oldHeader.slice();    var fieldsFromForm = getDataColumns(e.parameters);    var row = [new Date()]; // first element in the row should always be a timestamp        // loop through the header columns    for (var i = 1; i < oldHeader.length; i++) { // start at 1 to avoid Timestamp column      var field = oldHeader[i];      var output = getFieldFromData(field, e.parameters);      row.push(output);            // mark as stored by removing from form fields      var formIndex = fieldsFromForm.indexOf(field);      if (formIndex > -1) {        fieldsFromForm.splice(formIndex, 1);      }    }        // set any new fields in our form    for (var i = 0; i < fieldsFromForm.length; i++) {      var field = fieldsFromForm[i];      var output = getFieldFromData(field, e.parameters);      row.push(output);      newHeader.push(field);    }        // more efficient to set values as [][] array than individually    var nextRow = sheet.getLastRow() + 1; // get next row    sheet.getRange(nextRow, 1, 1, row.length).setValues([row]);    // update header row with any new data    if (newHeader.length > oldHeader.length) {      sheet.getRange(1, 1, 1, newHeader.length).setValues([newHeader]);    }  }  catch(error) {    Logger.log(error);  }  finally {    lock.releaseLock();    return;  }}function getDataColumns(data) {  return Object.keys(data).filter(function(column) {    return !(column === 'formDataNameOrder' || column === 'formGoogleSheetName' || column === 'formGoogleSendEmail' || column === 'honeypot');  });}function getFieldFromData(field, data) {  var values = data[field] || '';  var output = values.join ? values.join(', ') : values;  return output;}

Это просто спасло мне кучу времени и жизнь, ведь не реализовать как следует свою идею, это верх мучений. Как жить то потом?

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

function update() {   var sheetName1 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Tournament");   var sheetName2 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("TheMeets");   var sheetName3 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("TheMeets_sort");   var cellFunction1 = '=IMPORTHTML("https://fbref.com/en/comps/12/La-Liga-Stats","table",1)';  var cellFunction2 = '=sort({IMPORTHTML("https://fbref.com/en/comps/12/schedule/La-Liga-Scores-and-Fixtures","table",1);IMPORTHTML("https://fbref.com/en/comps/12/3239/schedule/2019-2020-La-Liga-Scores-and-Fixtures","table",1);IMPORTHTML("https://fbref.com/en/comps/12/1886/schedule/2018-2019-La-Liga-Scores-and-Fixtures","table",1);IMPORTHTML("https://fbref.com/en/comps/12/1652/schedule/2017-2018-La-Liga-Scores-and-Fixtures","table",1)},3,FALSE)';  var cellFunction3 = '=sort(IMPORTHTML("https://fbref.com/en/comps/12/schedule/La-Liga-Scores-and-Fixtures","table",1),3,TRUE)';      sheetName1.getRange('A1').setValue(cellFunction1);     sheetName2.getRange('A2').setValue(cellFunction2);    sheetName3.getRange('A1').setValue(cellFunction3);}

Выбираем таблицы и обновляем их. Так же добавляем в Google Apps Script триггер, на развертывание данного скрипта ежедневно. И вуаля!

УРА!

Я победил и смог довести дело до конца, сейчас все это выглядит в более ли менее адекватном виде:

Тут мы жмякаем на кнопку "Анализ статистики" и попадаем на страницу "Спасибо", которую я заюзал как задержку, чтобы дать возможность Google Sheets принять данные с формы, все посчитать, и выдать всю информацию. А на выходе получил красивую статистику матчей, и небольшие предсказания моей ванга-таблички.

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

Весь код, инструкция и как с помощью Google Apps Script складировать свои данные в Google Sheets здесь.

В двух словах на спортс.ру расписал, что я планировал учитывать в расчетах и как считать.

Подробнее..

Android Camera2 API от чайника, часть 6. Стрим видео сначала кодировали, теперь декодируем

20.06.2020 18:10:45 | Автор: admin

Итак, в предыдущем посте мы занимались кодированием живого видео формата H.264 на Android устройстве, которое затем отправляли для просмотра на персональный компьютер под виндой. Там наш видеопоток успешно раскодировывался и лицезрелся с помощью VLC плеера. А так же с помощью библиотеки VLCJ CAPRICA благополучно впихивался и в окошки JAVA приложения. Правда, каким именно образом он (VLC плеер) всё это проделывал, так и осталось загадкой. Но с другой стороны работает, да и ладно.

Подстольный настольный компьютер, ноутбук, лэптоп всё это прекрасно, но тем не менее, всё больше народа смотрит видео и управляет разными девайсами не из-за стола, а чаще валяясь на диване, со смартфоном в руках. И соответственно, к примеру, даже нашей роботележкой ныне удобней управлять именно оттуда. Поэтому настало время выяснить, как наш закодированный видео поток принять и отобразить на экране такого же Android устройства. Естественно, как и раньше мы проделаем всё через Camera2 API, концепцию Surface, да ещё и асинхронно!

Кому интересно вперёд.

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

Но это будет:
в следующей статье. Поскольку надо ещё и аудио канал прикрутить.

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

Этот эксперимент можно повторить с помощью нашего приложения, пары VR-очков для смартфона и естественно с помощью двух смартфонов. Одни очки хорошие и дорогие, которыми вы обычно пользуетесь, а другие Да, даже за четыреста рублей сойдут.

image

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

Если по существу, то изображение с камеры первого Android устройства передается на экран второго и раздваивается там (VR очки же). И наоборот, со второго девайса видео поток подается на первый в таком же порядке. То есть, вы видите то же, что должен видеть ваш партнер, а он (она), видит то, что должны видеть вы. Поскольку мы, в буквальном смысле, есть там, где есть наши глаза, ощущения будут у вас непередаваемые, особенно, если вы будете стараться двигаться синхронно и смотреть на свое (её) тело. Для неврологических экспериментов просто поле непаханное, ну или для камасутры всякой.

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

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

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

А находится он, как известно, в классе:

MEDIA CODEC


В пред пред предыдущем посте с его помощью мы кодировали в формате H.264 видео поток полученный с камеры и отправляли его по UDP каналу на нужный адрес. Поэтому, чтобы начать работу, нам весьма пригодится код из того самого поста. Чтобы не парить голову с отладкой программы на двух телефонах сразу, для начала мы сделаем всё на одном. Просто для понимания принципа работы. В одном окошке мы будем снимать видео, кодировать и отправлять его по домашней сетке самим себе во второе окошко. Всё по-честному, никаких localhost и адресов 127.0.0.1. Только настоящий сетевой адрес, только хардкор!

В сущности, в дополнение к уже имеющейся программе нам надо добавить:

  1. Изменение UI, то есть добавление второго окна и ассоциированного с ним Surface
  2. Блок кода для получения видео потока по UDP каналу
  3. Процедуру самого декодирования.

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

Итак, сначала модифицируем макет добавляем второе окно.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="match_parent"    tools:context=".MainActivity">    <TextureView        android:id="@+id/textureView"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="88dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent" />    <TextureView        android:id="@+id/textureView3"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="24dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView" />    <LinearLayout        android:layout_width="165dp"        android:layout_height="40dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView3"        app:layout_constraintVertical_bias="0.838">        <Button            android:id="@+id/button1"            android:layout_width="wrap_content"            android:layout_height="36dp"            android:text="вкл" />        <Button            android:id="@+id/button3"            android:layout_width="wrap_content"            android:layout_height="37dp"            android:text="выкл" />    </LinearLayout></androidx.constraintlayout.widget.ConstraintLayout>


Эта часть вообще объяснений не требует. Идем дальше.

Пишем код для получения дэйтаграмм


Тут есть пара тонкостей. Когда мы дэйтаграммы отправляли, то действовали совсем незамысловато. Мы дожидались, когда сработает коллбэк буфера выходных данных onOutputBufferAvailable, а затем легко и просто пихали полученный от него байтовый массив в UDP пакет. Дальше он уже без всякой помощи с нашей стороны уезжал по указанному адресу. Единственное, мы его ещё рубили на килобайтовые блоки (чтобы гарантированно влез в размер MTU), но сейчас это совершенно излишне (и даже, как мы увидим потом, вредно).

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

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

В итоге, код оказался несложным:

        Udp_recipient()        {            start();            mNewFrame = false;        }        public void run()        {                         try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                    mNewFrame = true;                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }             }        }

Итак, поток в наличии. После получения дэйтаграммы устанавливаем флаг NewFrame, чтобы декодер не путался и сбрасываем флаг перед прибытием нового UDP пакета. Здесь неявно предполагается, что декодер скуривает пакеты быстрее, чем они приходят. А это действительно, так и оказалось.

Переходим теперь к самому декодеру


Как и кодер он может работать в двух режимах синхронном и асинхронном. В первом случае вы учреждаете немало потоков, флагов и тщательно следите за тем, чтобы не впасть в состояние гонок (всё ж параллельное). Этот процесс сопровождается кучей стереотипного кода, в просторечии Boiler Plate, и в общем многократно где описан.

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

То ли считается, что там всё тривиально (а оно по сравнению с синхронным вариантом действительно так), то ли правда, оно у всех не работает. Разбираться опять придётся самим.

Итак, сначала учреждаем и инициализируем экземпляр декодера. Это вообще не сложно, поскольку он будет использовать формат кодера и нам только надо установить разрешение выходного окна в пихелях (это мое личное изобретение пихель, так сказать, родним русский и английский). Пока ограничимся скромным 640 на 480. Также надо будет присобачить декодер к Surface этого самого окна, чтобы было куда показывать.

       try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");

Далее обращаемся к коллбэкам. Нужных, как известно, два:

void onInputBufferAvailable(MediaCodec mc, int inputBufferId)void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, )

То есть, при срабатывании первого коллбэка мы в него кладём данные, а при вызове второго вынимаем готовенькое и отправляем на Surface. Может показаться странным, что когда мы делали кодирование (то есть, наоборот из Surface в кодек), то почему-то InputBuffer мы не использовали, а сразу волшебным образом доставали байтовые данные из OutputBuffer. Это мне тоже казалось странным, пока я не прочитал:
When using an input Surface, there are no accessible input buffers, as buffers are automatically passed from the input surface to the codec. Calling dequeueInputBuffer will throw an IllegalStateException, and getInputBuffers() returns a bogus ByteBuffer[] array that MUST NOT be written into.
Короче, автоматически это делается. Ну, и сделали бы при декодировании также. Но нет, придётся самим.

Итак, в метод void (MediaCodec mc, int inputBufferId) я ничтоже сумняшеся прописал:

 private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                             if(mNewFrame)                             {                               synchronized (outDataForEncoder)                               { b=outDataForEncoder;  }                             }                             decoderInputBuffer.put(b);                 codec.queueInputBuffer(index, 0, b.length,0, 0);              if(mNewFrame)              new Udp_recipient();        }

Ну, по аналогии, как мы делали в прошлый раз в кодере.

То есть, когда декодер вдруг ощущает, что ему срочно нужны данные, он вызывает этот коллбэк и кладёт наш прибывший udp-пакет (который уже доступен в виде байтового массива) в один из своих буферов под номером index. Там их вроде четыре. Естественно, ничего не заработало. Я ж забыл про onOutputBufferAvailable.

Туда тоже необходимо вставить две строчки:

    @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }

Причем значение True/False отвечает за то, будет ли содержимое буфера рендерится на Surface или выкинется на помойку.

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



А в логах обнаружились какие-то таинственные:

buffer descriptor with invalid usage bits 0x2000
A resource failed to call release.


Но большой ясности они не прибавляли. Некоторое понимание пришло из прошлых наблюдений. Дело в том, что я сначала не использовал флаг NewFrame (просто руки ещё не дошли) и декодер мог обратится к одному и тому же пришедшему пакету несколько раз (данные-то обрабатываются гораздо быстрее, чем приходят, как уже говорилось). На экране была, понятное дело, совершенная каша, но onInputBufferAvailable работал и работал без перебоев!

Оказалось, что имеет место удивительная вещь. Если коллбэк onInputBufferAvailable, сработал, то вынь да положь ему входные данные. Неважно какие, но положь. Иначе он дохнет. Поэтому пришлось предлагать ему байтовый массив нулевой длины в тех случаях, когда пакет актуальных данных уже был обработан, а новый ещё не пришёл. Как говорится, кому расскажешь не поверят.

Как только поправки были произведены, на Output Surface наконец-то появилось полученное через сеть видео.



Правда, как видно, на нём присутствуют некоторые недостатки. Посмотрев на них какое-то время, я стал в душе догадываться, что это все опять из-за onInputBufferAvailable. Ему снова что-то не нравилось. Оказалось, был не по вкусу байтовый массив. Я тогда про это не догадывался, мне лично не нравилась концепция флага NewFrame. Как-то она не сочеталась с реактивным программированием. Поэтому я решил полученные пакеты не просто класть в массив, а заворачивать этот массив в итоге в байтовый поток ByteArrayOutputStream. И пускай коллбэк, что ему надо, сам оттуда забирает, а конкретно каким образом, это его проблема.

Идея сработала блестяще и на экране я увидел это:

image

Согласитесь, прогресс существенный. Но опять чего-то не хватает. Или чего лишнее?

Посмотрев логи я понял что. Новые данные-то в поток кладутся нормально, но забираются каждый раз снова с самого начала. То есть, коллбэк читает при каждом вызове поток с с самого нулевого элемента, а не с начала нового пакета.

Решение было простым:

поток.reset();

И всё заработало как надо.



Теперь можно было насладиться законченным кодом, как обычно крайне минималистическим:

Листинг main_activity
package com.example.encoderdecoder;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity {    public static final String LOG_TAG = "myLogs";    public static Surface surface = null;    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mButtonOpenCamera1 = null;    private Button mButtonTStopStreamVideo = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    byte [] b;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    byte outDataForEncoder [];    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "your target address";// сюда пишете IP адрес телефона куда шлете //видео, но можно и себе    InetAddress address;    int port = 40002;    ByteArrayOutputStream  out;    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mButtonOpenCamera1 = findViewById(R.id.button1);        mButtonTStopStreamVideo = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mButtonOpenCamera1.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mButtonTStopStreamVideo.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            SurfaceTexture texture = mImageViewUp.getSurfaceTexture();            texture.setDefaultBufferSize(480, 640);            surface = new Surface(texture);            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);                mPreviewBuilder.addTarget(surface);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(surface, mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 480; // ширина видео            int height = 640; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер            mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                                    synchronized (out)                    {                            b =  out.toByteArray();                        out.reset();                          }                            decoderInputBuffer.put(b);                   codec.queueInputBuffer(index, 0, b.length,0, 0);        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );            //        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {          //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient()        {            out = new ByteArrayOutputStream(50000);            start();        }        public void run()        {            while (true)            {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                     synchronized (out)                            {out.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }            }        }        }    }


Как видно из листинга, мы выкинули из кодера фрагмент, где готовый байтовый массив рубился на кусочки длиной не более килобайта из-за опасения, что они могут не влезть в MTU. Опасения, как уже говорилось, оказались напрасными и даже вредными. Дело в том, (как мне показалось ) что кодер лепит эти массивы уже как некие смысловые единицы и соответственно декодер таким же порядком кладёт их в свои входные буферы. А если у нас килобайт попадает в один буфер, а хвостик в другой? Теперь-то скрывать уже нечего, но на самом деле, видео у меня красиво не показывало даже тогда, когда я учредил ByteArrayOutputStream. Не показывало до тех пор, пока я не выкинул этот злосчастный фрагмент кода.

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

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



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

Для полноты счастья оставалось только провести эксперименты с доступными разрешением и битрейтом. Я сначала хотел было поставить вполне приличные 1280 х 960, но для этого в кодеке H.264 нужен битрейт 5000-6000 Кбит / с. А при установке такой скорости мой декодер, к сожалению, со своей работой уже не справлялся. Пришлось ограничиться разрешением 640 х 480 (тем, что и так уже было) и подходящим для этого битрейтом 2000 Кбит / с. Потому что при приближении к скорости света к 3 000 000 бит/c, декодер начинает иметь бесконечную массу, валять дурака и отваливаться.

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

Сама программа особо не поменяется. Макет вообще трогать не будем. Нам всего лишь надо:

  1. Отключить первую Surface от камеры
  2. Подключить к ней декодер. Грубо говоря, скопировать окно.

Я думал, что задача окажется тривиальной, но как всегда ошибся. Казалось бы, надо просто создать вторую ссылку на экземпляр декодера и оно само всё скопируется во второе окно.
Действительно, если у вас к примеру два текстовых окна и вы хотите вывести в них значение переменной Int, то вы же не вычисляете это Int два раза. Вы просто пишете:

TextView1.setText(Int +  );TextView2.setText(Int +  );

Но видимо, поток данных в Surface не так прост как просто вывод текста. Если пробовать решить вопрос таким путем, то у вас будет выводится окно, которое было инициализировано последним. А первая инициализация исчезнет.

Чего я только не делал, даже textureView окна в макете пытался обозвать одинаково, ничего не помогало. На stockoverflow было на этот счёт мало чего (действительно, кому нафиг надо дублировать видео поток в два одинаковых окна на смартфоне), а в единственном обсуждении, что я нашёл, высказывалось туманное предположение, что один экземпляр Mediac Codec может связываться только с одной Surface.

Выход оказался совсем не элегантным. Надо скопировать видео поток во второе окно? Создавай второй экземпляр декодера, не больше ни меньше. Хорошо, что ещё не заставили второй раз данные по UDP каналу пересылать.

Но, тем тем не менее, при всей своей дубовости метод работает.

Листинг кода main_activity
package com.example.twovideosurfaces;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity  {    public static final String LOG_TAG = "myLogs";    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mOn = null;    private Button mOff = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    private MediaCodec decoder2 = null;    byte [] b;    byte [] b2;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    Surface mDecoderSurface2; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    ByteBuffer decoderInputBuffer2;    ByteBuffer decoderOutputBuffer2;    byte outDataForEncoder [];    static  boolean  mNewFrame=false;    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "192.168.50.131";    InetAddress address;    int port = 40002;    ByteArrayOutputStream out =new ByteArrayOutputStream(50000);    ByteArrayOutputStream out2 = new ByteArrayOutputStream(50000);    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mOn = findViewById(R.id.button1);        mOff = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mOn.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mOff.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                decoder2.stop();                decoder2.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 640; // ширина видео            int height = 480; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер           mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture= mImageViewUp.getSurfaceTexture();        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");        try {            decoder2 = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width2 = 480; // ширина видео        int height2 = 640; // высота видео        MediaFormat format2 = MediaFormat.createVideoFormat("video/avc", width2, height2);        format2.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture2= mImageViewDown.getSurfaceTexture();        mDecoderSurface2 = new Surface(texture2);        decoder2.configure(format2, mDecoderSurface2, null,0);        decoder2.setOutputSurface(mDecoderSurface2);        decoder2.setCallback(new DecoderCallback2());        decoder2.start();        Log.i(LOG_TAG, "запустили декодер");    }    private class DecoderCallback2 extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer2 = codec.getInputBuffer(index);            decoderInputBuffer2.clear();            synchronized (out2)            {                b2 =  out2.toByteArray();                out2.reset();            }            decoderInputBuffer2.put(b2);            codec.queueInputBuffer(index, 0, b2.length,0, 0);            if (b2.length!=0)            {                //   Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer2 = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer = codec.getInputBuffer(index);            decoderInputBuffer.clear();            synchronized (out)            {                b =  out.toByteArray();                out.reset();            }            decoderInputBuffer.put(b);            codec.queueInputBuffer(index, 0, b.length,0, 0);                 if (b.length!=0)            {               //  Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                //  Log.i(LOG_TAG, " outDate.length : " + outDate.length);                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient() {            start();            //    Log.i(LOG_TAG, "запустили прием данных по udp");        }        public void run() {            while (true) {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                    synchronized (outDataForEncoder) {                        for (int i = 0; i < outDataForEncoder.length; i++) {                            outDataForEncoder[i] = bBuffer[i];                        }                    }                    mNewFrame = true;                    synchronized (out)                    {out.write(outDataForEncoder);}                    synchronized (out2)                    {out2.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "hggh ");                }            }        }    }}       



Теперь в таргет адресе первого смартофона:

String ip_address = " допустим 192.168.50.131";

указываем адрес его визави и наоборот. Запускаем приложения, надеваем VR-очки и вперёд, двигать науку нейробиологию!
Подробнее..

Малиновый киноцентр или как сделать неубиваемый смарт-ТВ

01.09.2020 02:06:58 | Автор: admin

Предыстория

Шёл 2018 год. У меня сломался старый DVD-магнитофон компании BBK. Выглядел он, примерно, так:

Умерший дивидюшникУмерший дивидюшник

Знаю, что скажут 90% читателей: "Зачем тебе магнитофон? Смарт-ТВ купи и счастье". Я отвечу. Проблема в том, что ещё в 90-ых был куплен шикарный телевизор с 5.1 звуком и Full-HD монитором, правда не стандартного разрешения (не 4:3 и не 16:9). Менять телевизор и попадать минимум на 40 тысяч из-за дивидюшника за 3 тысячи - как-то не разумно. В планах сделать экран с проектором и звуком, но вот покупать смарт-ТВ, который не поковыряешь - для меня слишком больно. Купил Sony - мучайся с их смартом и так с любой фирмой.

Так вот. Пошёл я в магазин и увидел 3 варианта DVD-магнитофонов:

  • Panasonic, Philips, Sony и т.п. за 10 тысяч

  • Шлакоблок-ноунейм за 500 рублей

  • Ну и банально, мой умерший BBK за 2,5 тысячи

Проведя серьёзное исследование вопроса (нет), я понял, что есть ряд косяков в каждом варианте. По порядку:

  • Магнитофон за 10 тысяч - вещь очень капризная и нудная. Читает только конкретный формат видео (в основном .avi), капризен к размеру файла - до 4 ГБ, а где-то и до 1,2 (шёл 2018 год, а видео больше 1,2 гигов не читаются), да и цена вопроса - печаль-тоска. Плюшки в виде записи телепрограмм или же Blu-ray привода - очень условны, так как запись читается (в плане без костылей и страданий) только на этом магнитофоне. ПК - в пролёте (2018 год!). А хвастать Blu-ray приводом - это как-то совсем уж печально.

  • Шлакоблок работает 3 раза, да и ставить не известно что к хорошему телевизору - печаль.

  • Покупать свой же магнитофон за те же деньги и на то же время работы (3 тысячи на 3 года) - мне аж плакать захотелось.

Тут меня посетила идея сделать всё самому.

Подготовка

В моём распоряжении был старый комп-башня и куча старых деталей, который валялись по квартире и офису. Тестил всё на этом железе в разных конфигурациях: от сборки с интегрированной видюхой от интел с 1 ГБ оперативы и пентиумом на борту, до GTX 660 с 8 ГБ оперативы и i5 во главе. Разница есть, но только в загрузке файлов - то есть не критичная. Картинка в FullHD выдаётся ровно без крашей всю дорогу. Имея задумку повесить экран с проектором, делать громоздкую станцию - не вариант.

Выбор пал на малинку (на тот момент - 3 Model B+). Чтобы не заморачиваться с поиском деталей на алике - я использовал стартовый набор за 5 тысяч. Бюджет вполне утраивал.

Комплектация:

  • Малинка

  • Корпус под плату

  • 3 радиатора с термопастой-двойным скотчем

  • Карта на 16 гигов с переходником

  • Кабель питания

  • HDMI на пол метра

Комплектация малинки Комплектация малинки

Комплект не жирный, но всё что нужно на месте. Брал тут, но сейчас ценник явно завышен.

Если будете брать сейчас - берите 4 Model B. Смысл тот же, но сама плата помощнее. Набор на том же сайте. Разница 600 рублей, но сейчас цены завышены.

Проблема 2

Если коротко - проблема ниже

ТюльпанТюльпан

Во всех старых телевизорах старые RCA-разъёмы (тюльпаны), а это большая проблема, так как разъём аналоговый, а малинка работает только в цифре.

Задача - найти переходник в FullHD с отдельным выходом на питание. Дело в том, что малинка - энергоэкономичное устройство и она не может сама запитать по HDMI переходник.

Боже храни AliExpress.

ПереходникПереходник

170 рублей и нет проблем. Питание по USB всё от той же малинки.

Операционная система

У меня было 2 варианта операционной системы - костыльный Android и система Kodi. Я выбрал Kodi с сиcтемой LibreELEC. Во-первых, это система рассчитана именно под мою задачу - создание киноцентра, а во-вторых, система полностью настраиваемая.

Установка проста как мир. Загружаем установщик -> на шаге 1, выбираем платформу (в моём случае, Raspberri Pi 3) и версию системы (просто, последнюю версию) -> жмём Download на шаге 2 -> вставляем microSD с переходником из комплекта -> на шаге 3 выбираем карту -> жмём "Write" на шаге 4.

Установщик системыУстановщик системы

После завершения настроек карты, система готова к использованию. Вставляем её в малину и запускаем.

Базовые настройки

До меня многие люди делали примерную настройку системы. Мы же сделаем всё от и до.

Итак, система на базе UNIX, а значит настроить можно всё. Система встречает нас на русском языке и это победа!

Экран настроекЭкран настроек

Калибровка

Напомню, мой ТВ не формат (не 4:3 и не 16:9). Чиниться всё легко. Идём в настройки системы.

СистемаСистема

Переходим во вкладку "Экран". Там автоматом стоит 1920х1080 и это хорошо, так как переходник на RCA вещает в 1080 (если подключали по HDMI, система сама определит оптимальное разрешение). Идём в самый низ меню во вкладку "Калибровка дисплея". Если она не отобразилась сразу - переключитесь на "Экспертный режим" (кликаем внизу левого меню).

КалибровкаКалибровка

Калибруется всё 4 ползунками:

КалибруемКалибруем

Углы необходимо поставить в угол вашего ТВ, квадрат выровнять до квадрата (можно на глаз, можно по координатам) и выбираем место для субтитров.

Мы видим ровную картинку с любого ТВ/экрана/проектора.

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

Телефон - это пульт

Тут всё просто. Скачиваем на свой смартфон/планшет программу из AppStore или Google Play. Если вы с малинкой в одной сети - приложение схватит всё само.

DVD

Чтобы мы могли читать диски, необходимо докупить DVD-привод. Какой по душе. Я не поскупился и купил бесшумную модель - Hitachi-LG GP60NB60. Минус 2 тысячи из бюджета.

DVD-приводDVD-привод

Итого, магнитофон, который читает любые файлы, управляется с телефона и читает DVD. Не плохо, но для 7 тысяч - маловато функционала.

Продолжим!

Дополнения до смарта

Идём во вкладку "Дополнения" и скачиваем все указанные:

Дополнения 1Дополнения 1Дополнения 2Дополнения 2Дополнения 3Дополнения 3

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

Bing-обои

Эта штука мне понравилась ещё в заставках Windows. Когда вы отойдёте от малинки, а она включена - через 2 минуты будет появляться еженедельно обновляемые фото-победители на Bing. Всё что нужно - дополнение "Bing: Photos of the Week".

Переходим во вкладку "Интерфейс".

ИнтерфейсИнтерфейс

Выбираем вкладку "Заставка".

ЗаставкаЗаставка

В этой вкладке выбираем наше дополнение

BingBing

Модно, стильно, молодёжно. Едем дальше.

Региональные настройки

Если что-то нужно поменять - переходим во вкладку "Интерфейс".

ИнтерфейсИнтерфейс

Вкладка "Региональные" и меняем как удобно. У меня выставлены следующие настройки:

Региональные натсройкиРегиональные натсройки

Изменение настроек системы

Если что-то не устраивает в системе глобально - перейдите во вкладку "LibreELEC".

LibreELECLibreELEC

Я чаще всего захожу в эту вкладку для подключения к интернету на новом месте или для работы с SSH.

Wi-FiWi-Fi

Погода

Я пользуюсь приложение Gismeteo и оно весьма точно предсказывает погоду. Баловство, конечно, в ТВ, но пусть будет :)

Вкладка "Службы".

СлужбыСлужбы

Кнопка погоды и приложение Gismeteo.

ПогодаПогодаGismeteoGismeteo

IPTV-сила

Настроем IPTV. Вкладка дополнения и в ней приложение "PVR IPTV Simple Client".

PVR IPTV Simple ClientPVR IPTV Simple Client

Жмахаем на неё и настраиваем напрямую.

Настройки IPTVНастройки IPTV

Тут всё просто. Вписывает ссылку на M3U. Далее в разделе"Установка EPG", можно указать путь до программы передач. Для этого выберите пункт"Ссылка на XMLTV".

Ссылка на M3UСсылка на M3U

Выбор IPTV

Тут дилемма: бесплатно и так себе или платно и хорошо. Решать вам, но я расскажу об обоих вариантах.

Бесплатное IPTV

Лучшее, что я смог найти - Самообновляемый плейлист "ONE". Это бесплатный самообновляемый плейлист в формате m3u. Разрабы обещают, что плейлист будет всегда бесплатным.

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

Платное IPTV

Тут, на мой взгляд, лучший вариант - sharavoz. За 3 бакса (т.е. 250 рублей) вы получите все каналы НТВ+, стандартное вещание и кучу плюшек. У ребят 1 день бесплатного теста. Попробуйте. Я остановился на этом варианте.

YouTube жив!

Почему так пафосно? Дело в том, что в прошлом году пришли какие-то черти и сломали YouTube. Доступ к API в Kodi закрыли и система потеряла смысл.

Однако! Ребята в Kodi смогли придумать способ обхода блокировки и этот способ хоть и не прост, но вечен.

Итак. Переходим по ссылке. Вводим логин-пароль.

Заходим в настройки YouTube через дополнения.

YouTubeYouTubeНастройкиНастройки

Далее, по инструкции ниже.

Ввод в системе, а не через SSHВвод в системе, а не через SSH

Аналоги на рынке

Аналог сделанного нами устройства - Xiaomi Mi Box.

Xiaomi Mi BoxXiaomi Mi Box

Цена вопроса - 5000 рублей. Без дисковода - цена один в один, но есть 2 весомых косяка:

  1. Android, который работает весьма кривенько

  2. Невозможность подстроить экран по разрешение (калибровка в Kodi)

Из-за этих двух весьма сильных косяка - устройство для меня стало абсолютно бесполезно, хотя если экран будет стандартного разрешения 16 к 9 - будет всё нормально, но это только в таком раскладе и с андроидом в коробке

Итоги

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

  • Подстройка под нестандартные экраны

  • Работа как по цифре (HDMI), так и по аналогу (тюльпан)

  • Настроено IPTV

  • Выносной DVD-привод

  • Приятное оформление

  • Настроен YouTube, который пытались вырезать из системы

  • Цена - 7 000 рублей

  • Устройство можно перетаскивать с собой в поездки и при возвращении и подключении к домашнему ТВ - настройки сохраняются

Подробнее..

Настройка Gmail API для замены расширения PHP IMAP и работы по протоколу OAuth2

25.08.2020 12:13:42 | Автор: admin
Оказавшись одним из счастливчиков, совершенно не готовым к тому, что с 15 февраля 2021 года авторизация в Gmail и других продуктах будет работать только через OAuth, я прочитал статью "Google хоронит расширение PHP IMAP" и загрустил начал предпринимать действия по замене расширения PHP IMAP в своём проекте на API Google. Вопросов было больше, чем ответов, поэтому заодно нацарапал мануал.

У меня PHP IMAP используется для следующих задач:
  1. Удаление старых писем из почтовых ящиков. К сожалению, в панели управления корпоративным аккаунтом G Suite можно настроить только срок удаления писем из всех почтовых ящиков организации через N дней после получения. Мне же требуется удалять письма только в заданных почтовых ящиках и через заданное разное количество дней после получения.
  2. Фильтрация, разбор и маркировка писем. С нашего сайта в автоматическом режиме отправляется множество писем, некоторые из которых не доходят до адресатов, о чём, соответственно, приходят отчёты. Нужно эти отчёты отлавливать, разбирать, находить клиента по email и формировать человекочитаемое письмо для менеджера, чтобы тот связался с клиентом и уточнил актуальность адреса электронной почты.


Эти две задачи мы и будем решать при помощи API Gmail в данной статье (а заодно и отключим в настройках почтовых ящиков доступ для небезопасных приложений, который был включён для работы PHP IMAP, и, собственно, перестанет работать в страшный день в феврале 2021). Использовать будем так называемый сервисный аккаунт приложения Gmail, который при соответствующей настройке даёт возможность подключения ко всем почтовым ящикам организации и выполнения в них любых действий.

1. Создаём проект в консоли разработчика Google API


При помощи этого проекта мы и будем осуществлять API-взаимодействие с Gmail, и в нём же создадим тот самый сервисный аккаунт.
Для создания проекта:
  1. Переходим в консоль разработчика Google API и логинимся по администратором G Suite (ну или кто у Вас там пользователь со всеми правами)
  2. Ищем кнопку Создать проект.
    Я нашёл здесь:
    image

    И затем здесь:
    image


    Заполняем имя проекта и сохраняем
    Создание проекта
    image

  3. Переходим к проекту и нажимаем кнопку Включить API и сервисы
    Включить API и сервисы
    image

    Выбираем Gmail API


2. Создаём и настраиваем сервисный аккаунт


Для этого можно воспользоваться официальным мануалом или продолжить чтение:
  1. Переходим в наш добавленный Gmail API, нажимаем кнопку Создать учётные данные и выбираем Сервисный аккаунт
    Создание сервисного аккаунта
    image


    Что-нибудь заполняем и нажимаем Создать
    Сведения о сервисном аккаунте
    image


    Всё остальное можно не заполнять
    Права доступа для сервисного аккаунта
    image

    Предоставление пользователям доступа к сервисному аккаунту
    image

  2. Далее, сервисному аккаунту нужно дать права на чтение или управление почтовыми ящиками. Для этого переходим в консоль администрирования G Suite, открываем главное меню и переходим в пункт Безопасность Управление API.
    Управление API
    image
    image

  3. Прокручиваем страницу вниз и выбираем пункт Настроить делегирование доступа к данным в домене
    Делегирование доступа к данным в домене
    image

    Нажимаем Добавить, в поле Идентификатор клиента копируем соответствующую строку из карточки сервисного аккаунта, а поле Области действия OAuth вставляем права одно или несколько из следующих значений через запятую:

    - https://mail.google.com/ - для полного доступа
    - https://www.googleapis.com/auth/gmail.modify - для редактирования меток
    - https://www.googleapis.com/auth/gmail.readonly - для чтения
    - https://www.googleapis.com/auth/gmail.metadata - для доступа к метаданным


    Сведения о сервисном аккаунте
    image
    image

  4. Возвращаемся к карточке сервисного аккаунта и включаем ещё одну разрешающую галку Включить делегирование доступа к данным в домене G Suite:
    Статус сервисного аккаунта
    image

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

    Для этого со страницы Учётные данные Вашего проекта переходим по ссылке Управление сервисными аккаунтами
    Учётные данные
    image


    и выбираем Действия Создать ключ, тип: JSON
    Управление сервисными аккаунтами
    image


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

На этом настройка API Gmail закончена, далее будет немного моего кака-кода, собственно, реализующего функции, которые до сих пор решались расширением IMAP PHP.

3. Пишем код


По API Gmail есть вполне себе неплохая официальная документация (клик и клик), которой я и пользовался. Но раз уж взялся написать подробный мануал, то приложу и свой собственный кака-код.

Итак, первым делом устанавливаем Google Client Library (apiclient) при помощи composer:

composer require google/apiclient

(Сначала я, как истинный буквоед, установил именно версию 2.0 api-клиента, как указано в PHP Quickstart, но при первом же запуске на PHP 7.4 посыпались всякие ворнинги и алармы, поэтому Вам так же делать не советую)

Затем на основе примеров из официальной документации пишем свой класс для работы с Gmail, не забывая указать файл ключа сервисного аккаунта:
Класс для работы с Gmail
<?php// Класс для работы с Gmailclass GmailAPI{    private $credentials_file = __DIR__ . '/../Gmail/credentials.json'; // Ключ сервисного аккаунта    // ---------------------------------------------------------------------------------------------    /**     * Функция возвращает Google_Service_Gmail Authorized Gmail API instance     *     * @param  string $strEmail Почта пользователя     * @return Google_Service_Gmail Authorized Gmail API instance     * @throws Exception     */    function getService(string $strEmail){        // Подключаемся к почтовому ящику        try{            $client = new Google_Client();            $client->setAuthConfig($this->credentials_file);            $client->setApplicationName('My Super Project');            $client->setScopes(Google_Service_Gmail::MAIL_GOOGLE_COM);            $client->setSubject($strEmail);            $service = new Google_Service_Gmail($client);        }catch (Exception $e) {            throw new \Exception('Исключение в функции getService: '.$e->getMessage());        }        return $service;    }    // ---------------------------------------------------------------------------------------------    /**     * Функция возвращает массив ID сообщений в ящике пользователя     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  array $arrOptionalParams любые дополнительные параметры для выборки писем     * Из них мы сделаем стандартную строку поиска в Gmail вида after: 2020/08/20 in:inbox label:     * и запишем её в переменную q массива $opt_param     * @return array Массив ID писем или массив ошибок array('arrErrors' => $arrErrors), если они есть     * @throws Exception     */    function listMessageIDs(Google_Service_Gmail $service, string $strEmail, array $arrOptionalParams = array()) {        $arrIDs = array(); // Массив ID писем        $pageToken = NULL; // Токен страницы в почтовом ящике        $messages = array(); // Массив писем в ящике        // Параметры выборки        $opt_param = array();        // Если параметры выборки есть, делаем из них строку поиска в Gmail и записываем её в переменную q        if (count($arrOptionalParams)) $opt_param['q'] = str_replace('=', ':', http_build_query($arrOptionalParams, null, ' '));        // Получаем массив писем, соответствующих условию выборки, со всех страниц почтового ящика        do {            try {                if ($pageToken) {                    $opt_param['pageToken'] = $pageToken;                }                $messagesResponse = $service->users_messages->listUsersMessages($strEmail, $opt_param);                if ($messagesResponse->getMessages()) {                    $messages = array_merge($messages, $messagesResponse->getMessages());                    $pageToken = $messagesResponse->getNextPageToken();                }            } catch (Exception $e) {                throw new \Exception('Исключение в функции listMessageIDs: '.$e->getMessage());            }        } while ($pageToken);        // Получаем массив ID этих писем        if (count($messages)) {            foreach ($messages as $message) {                $arrIDs[] = $message->getId();            }        }        return $arrIDs;    }    // ---------------------------------------------------------------------------------------------    /**     * Удаляем сообщения из массива их ID функцией batchDelete     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  array $arrIDs массив ID писем для удаления из функции listMessageIDs     * @throws Exception     */    function deleteMessages(Google_Service_Gmail $service, string $strEmail, array $arrIDs){        // Разбиваем массив на части по 1000 элементов, так как столько поддерживает метод batchDelete        $arrParts = array_chunk($arrIDs, 999);        if (count($arrParts)){            foreach ($arrParts as $arrPartIDs){                try{                    // Получаем объект запроса удаляемых писем                    $objBatchDeleteMessages = new Google_Service_Gmail_BatchDeleteMessagesRequest();                    // Назначаем удаляемые письма                    $objBatchDeleteMessages->setIds($arrPartIDs);                    // Удаляем их                    $service->users_messages->batchDelete($strEmail,$objBatchDeleteMessages);                }catch (Exception $e) {                    throw new \Exception('Исключение в функции deleteMessages: '.$e->getMessage());                }            }        }    }    // ---------------------------------------------------------------------------------------------    /**     * Получаем содержиме сообщения функцией get     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  string $strMessageID ID письма     * @param  string $strFormat The format to return the message in.     * Acceptable values are:     * "full": Returns the full email message data with body content parsed in the payload field; the raw field is not used. (default)     * "metadata": Returns only email message ID, labels, and email headers.     * "minimal": Returns only email message ID and labels; does not return the email headers, body, or payload.     * "raw": Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used.     * @param  array $arrMetadataHeaders When given and format is METADATA, only include headers specified.     * @return  object Message     * @throws Exception     */    function getMessage(Google_Service_Gmail $service, string $strEmail, string $strMessageID, string $strFormat = 'full', array $arrMetadataHeaders = array()){        $arrOptionalParams = array(            'format' => $strFormat // Формат, в котором возвращаем письмо        );        // Если формат - metadata, перечисляем только нужные нам заголовки        if (($strFormat == 'metadata') and count($arrMetadataHeaders))            $arrOptionalParams['metadataHeaders'] = implode(',',$arrMetadataHeaders);        try{            $objMessage = $service->users_messages->get($strEmail, $strMessageID,$arrOptionalParams);            return $objMessage;        }catch (Exception $e) {            throw new \Exception('Исключение в функции getMessage: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------    /**     * Выводим список меток, имеющихся в почтовом ящике     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @return  object $objLabels - объект - список меток     * @throws Exception     */    function listLabels(Google_Service_Gmail $service, string $strEmail){        try{            $objLabels = $service->users_labels->listUsersLabels($strEmail);            return $objLabels;        }catch (Exception $e) {            throw new \Exception('Исключение в функции listLabels: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------    /**     * Добавляем или удаляем метку (флаг) к письму     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  string $strMessageID ID письма     * @param  array $arrAddLabelIds Массив ID меток, которые мы добавляем к письму     * @param  array $arrRemoveLabelIds Массив ID меток, которые мы удаляем в письме     * @return  object Message - текущее письмо     * @throws Exception     */    function modifyLabels(Google_Service_Gmail $service, string $strEmail, string $strMessageID, array $arrAddLabelIds = array(), array $arrRemoveLabelIds = array()){        try{            $objPostBody = new Google_Service_Gmail_ModifyMessageRequest();            $objPostBody->setAddLabelIds($arrAddLabelIds);            $objPostBody->setRemoveLabelIds($arrRemoveLabelIds);            $objMessage = $service->users_messages->modify($strEmail,$strMessageID,$objPostBody);            return $objMessage;        }catch (Exception $e) {            throw new \Exception('Исключение в функции modifyLabels: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------}


При любом взаимодействии с Gmail первым делом мы вызываем функцию getService($strEmail) класса GmailAPI, которая возвращает авторизованный объект для работы с почтовым ящиком $strEmail. Далее этот объект уже передаётся в любую другую функцию для уже непосредственно выполнения нужных нам действий. Все остальные функции в классе GmailAPI уже выполняют конкретные задачи:

  • listMessageIDs находит письма по заданным критериям и возвращает их ID (передаваемая в функцию listUsersMessages Gmail API строка поиска писем должна быть аналогична строке поиска в веб-интерфейсе почтового ящика),
  • deleteMessages удаляет письма с переданными в неё ID (функция batchDelete API Gmail удаляет не более 1000 писем за один проход, поэтому пришлось разбить массив переданных в функцию ID на несколько массивов по 999 писем и выполнить удаление несколько раз),
  • getMessage получает всю информацию о сообщении с переданным в неё ID,
  • listLabels возвращает список флагов в почтовом ящике (я использовал её, чтобы получить ID флага, который изначально был создан в веб-интерфейсе ящика, и присваивается нужным сообщениям)
  • modifyLabels добавляет или удаляет флаги к сообщению


Далее, у нас есть задача удаления старых писем в различных почтовых ящиках. При этом старыми мы считаем письма, полученные своё количество дней назад для каждого почтового ящика. Для реализации этой задачи пишем следующий скрипт, ежедневно запускаемый cron'ом:
Удаление старых писем
<?php/** * Удаляем письма в почтовых ящиках Gmail * Используем сервисный аккаунт и его ключ */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент// Задаём количества дней хранения почты в ящиках$arrMailBoxesForClean = array(    'a@domain.com' => 30,    'b@domain.com' => 30,    'c@domain.com' => 7,    'd@domain.com' => 7,    'e@domain.com' => 7,    'f@domain.com' => 1);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail// Проходим по списку почтовых ящиков, из которых нужно удалить старые письмаforeach ($arrMailBoxesForClean as $strEmail => $intDays) {    try{        // Подключаемся к почтовому ящику        $service = $objGmailAPI->getService($strEmail);        // Указываем условие выборки писем в почтовом ящике        $arrParams = array('before' => date('Y/m/d', (time() - 60 * 60 * 24 * $intDays)));        // Получаем массив писем, подходящих для удаления        $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail,$arrParams);        // Удаляем письма по их ID в массиве $arrIDs        if (count($arrIDs)) $objGmailAPI->deleteMessages($service,$strEmail,$arrIDs);        // Удаляем все использованные переменные        unset($service,$arrIDs);    }catch (Exception $e) {        $arrErrors[] = $e->getMessage();    }}if (count($arrErrors)){    $strTo = 'my_email@domain.com';    $strSubj = 'Ошибка при удалении старых писем из почтовых ящиков';    $strMessage = 'При удалении старых писем из почтовых ящиков возникли следующие ошибки:'.        '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'.        '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL);    $objMailSender = new mailSender();    $objMailSender->sendMail($strTo,$strSubj,$strMessage);}


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

Задача формирования отчётов для менеджера о недоставленных письмах на основании автоматических отчётов решается следующим скриптом:
Фильтрация и маркировка писем
<?php/* * Подключаемся к ящику a@domain.com * Берём с него письма о том, что наши письма не доставлены: отправитель: mailer-daemon@googlemail.com * Проверяем почтовые ящики в этих письмах. Если они есть у клиентов на нашем сайте, отправляем на b@domain.com * письмо об этом */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент$strEmail = 'a@domain.com';$strLabelID = 'Label_2399611988534712153'; // Флаг reportProcessed - устанавливаем при обработке письма// Параметры выборки$arrParams = array(    'from' => 'mailer-daemon@googlemail.com', // Письма об ошибках приходят с этого адреса    'in' => 'inbox', // Во входящих    'after' => date('Y/m/d', (time() - 60 * 60 * 24)), // За последние сутки    'has' => 'nouserlabels' // Без флага);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail$arrClientEmails = array(); // Массив адресов электронной почты, на которые не удалось отправить сообщениеtry{    // Подключаемся к почтовому ящику    $service = $objGmailAPI->getService($strEmail);    // Находим в нём отчёты за последние сутки о том, что письма не доставлены    $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail, $arrParams);    // Для найденных писем получаем заголовок 'X-Failed-Recipients', в котором содержится адрес, на который пыталось быть отправлено письмо    if (count($arrIDs)){        foreach ($arrIDs as $strMessageID){            // Получаем метаданные письма            $objMessage = $objGmailAPI->getMessage($service,$strEmail,$strMessageID,'metadata',array('X-Failed-Recipients'));            // Заголовки письма            $arrHeaders = $objMessage->getPayload()->getHeaders();            // Находим нужный            foreach ($arrHeaders as $objMessagePartHeader){                if ($objMessagePartHeader->getName() == 'X-Failed-Recipients'){                    $strClientEmail = mb_strtolower(trim($objMessagePartHeader->getValue()), 'UTF-8');                    if (!empty($strClientEmail)) {                        if (!in_array($strClientEmail, $arrClientEmails)) $arrClientEmails[] = $strClientEmail;                    }                    // Помечаем письмо флагом reportProcessed, чтобы не выбирать его в следующий раз                    $objGmailAPI->modifyLabels($service,$strEmail,$strMessageID,array($strLabelID));                }            }        }    }    unset($service,$arrIDs,$strMessageID);}catch (Exception $e) {    $arrErrors[] = $e->getMessage();}// Если найдены адреса электронной почты, на которые не удалось доставить сообщения, проверяем их в базеif (count($arrClientEmails)) {    $objClients = new clients();    // Получаем все email всех клиентов    $arrAllClientsEmails = $objClients->getAllEmails();    foreach ($arrClientEmails as $strClientEmail){        $arrUsages = array();        foreach ($arrAllClientsEmails as $arrRow){            if (strpos($arrRow['email'], $strClientEmail) !== false) {                $arrUsages[] = 'как основной email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }            if (strpos($arrRow['email2'], $strClientEmail) !== false) {                $arrUsages[] = 'как дополнительный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }            if (strpos($arrRow['site_user_settings_contact_email'], $strClientEmail) !== false) {                $arrUsages[] = 'как контактный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }        }        $intUsagesCnt = count($arrUsages);        if ($intUsagesCnt > 0){            $strMessage = 'Не удалось доставить письмо с сайта по адресу электронной почты <span style="color: #000099;">'.$strClientEmail.'</span><br/>                Этот адрес используется';            if ($intUsagesCnt == 1){                $strMessage .= ' '.$arrUsages[0].'<br/>';            }else{                $strMessage .= ':<ul>';                foreach ($arrUsages as $strUsage){                    $strMessage .= '<li>'.$strUsage.'</li>';                }                $strMessage .= '</ul>';            }            $strMessage .= '<br/>Пожалуйста, уточните у клиента актуальность этого адреса электронной почты.<br/><br/>                Это письмо было отправлено автоматически, не отвечайте на него';            if (empty($objMailSender)) $objMailSender = new mailSender();            $objMailSender->sendMail('b@domain.com','Проверьте email клиента',$strMessage);        }    }}if (count($arrErrors)){    $strTo = 'my_email@domain.com';    $strSubj = 'Ошибка при обработке отчётов о недоставленных письмах';    $strMessage = 'При обработке отчётов о недоставленных письмах возникли следующие ошибки:'.        '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'.        '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL);    if (empty($objMailSender)) $objMailSender = new mailSender();    $objMailSender->sendMail($strTo,$strSubj,$strMessage);}


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

Исходники доступны на GitHub.

Вот и всё, что я хотел поведать в этой статье. Спасибо за прочтение! Если у Вас защипало в глазах от моего кода, просто сверните спойлер или напишите свои замечания буду рад конструктивной критике.
Подробнее..

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

03.02.2021 10:04:43 | Автор: admin
Привет, Хабр!

У нас выходит долгожданное второе издание книги "Веб-разработка с применением Node и Express".



В рамках исследования этой темы нами была найдена концептуальная статья о проектировании веб-API по модели, где вместо ключей и значений базы данных применяются ссылки на ресурсы. Оригинал из блога Google Cloud, добро пожаловать под кат.


Когда мы занимаемся моделированием информации, ключевой вопрос в том, как обозначить отношение и взаимоотношение между двумя сущностями. Описывать закономерности, наблюдаемые в реальном мире, в терминах сущностей и их взаимоотношений это фундаментальная идея, восходящая, как минимум, к Древней Греции. Она играет основополагающую роль и в современной IT-отрасли.

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

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

Наиболее распространенный способ, применяемый разработчиками API для выражения взаимоотношений это предоставление ключей базы данных или прокси для них в полях тех сущностей, которые ассоциированы с этими ключами. Однако, как минимум в одном классе API (веб-ориентированных) такому подходу есть более предпочтительная альтернатива: использование веб-ссылок.

Согласно стандарту Internet Engineering Task Force (IETF), веб-ссылку можно представить как инструмент для описания отношений между страницами в вебе. Наиболее известные веб-ссылки те, что фигурируют на HTML-страницах и заключаются в элементы link или anchor, либо в заголовки HTTP. Но ссылки также могут фигурировать и в ресурсах API, а при использовании их вместо внешних ключей существенно сокращается объем информации, которую поставщику API приходится дополнительно документировать, а пользователю изучать.

Ссылка это элемент в одном веб-ресурсе, содержащий указание на другой веб-ресурс, а также название взаимоотношения между двумя ресурсами. Ссылка на другую сущность записывается в особом формате, именуемом уникальный идентификатор ресурса (URI), для которого существует стандарт IETF. В этом стандарте словом ресурс называется любая сущность, на которую указывает URI. Название типа отношения в ссылке можно считать аналогичным имени того столбца базы данных, в котором содержатся внешние ключи, а URI в ссылке аналогичен значению внешнего ключа. Наиболее полезны из всех URI те, по которым можно получить информацию о ресурсе, на который проставлена ссылка, при помощи стандартного веб-протокола. Такие URI называются унифицированный указатель ресурса (URL) и наиболее важной разновидностью URL для API являются HTTP URL.

В то время как ссылки не находят широкого применения в API, некоторые очень известные веб-API все-таки основаны на HTTP URL, используемых в качестве средства представления взаимоотношений. Таковы, например, Google Drive API и GitHub API. Почему так складывается? В этой статье я покажу, как на практике строится использование внешних ключей API, объясню их недостатки по сравнению с использованием ссылок, и расскажу, как преобразовать дизайн, использующий внешние ключи, в такой, где применяются ссылки.

Представление взаимоотношений при помощи внешних ключей


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

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



Взаимоотношение между Лесси и Джо выражается так: в представлении Лесси Джо обозначен как обладатель имени и значения, соответствующих владельцу. Обратное взаимоотношение не выражено. Значение владельца равно 98765, это внешний ключ. Вероятно, это самый настоящий внешний ключ базы данных то есть, перед нами значение первичного ключа из какой-то строки какой-то таблицы базы данных. Но, даже если реализация API немного изменит значения ключей, она все равно сближается по основным характеристикам с внешним ключом.
Значение 98765 не слишком годится для непосредственного использования клиентом. В наиболее распространенных случаях клиенту, воспользовавшись этим значением, нужно составить URL, а в документации к API необходимо описать формулу для выполнения такого преобразования. Как правило, это делается путем определения шаблона URI, вот так:

/people/{person_id}

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

/pets?owner={person_id}
/people/{person_id}/pets


В API, спроектированных по такому принципу, обычно требуется определять и документировать много шаблонов URI. Наиболее популярным языком для определения таких шаблонов является не тот, что задан в спецификации IETF, а язык OpenAPI (ранее известный под названием Swagger). До версии 3.0 в OpenAPI не существовало способа указать, какие значения полей могут быть вставлены в какие шаблоны, поэтому часть документации требовалось составлять на естественном языке, а что-то приходилось угадывать клиенту. В версии 3.0 OpenAPI появился новый синтаксис под названием links, призванный решить эту проблему, но для того, чтобы пользоваться этой возможностью последовательно, надо потрудиться.

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

Представление взаимоотношений при помощи ссылок


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



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

Обратите внимание: обратное взаимоотношение, то есть, от питомца к владельцу, теперь тоже реализовано явно, поскольку к представлению Joel добавлено поле "pets".

Изменение "id" на "self", в сущности, не является необходимым или важным, но существует соглашение, что при помощи "self" идентифицируется ресурс, чьи атрибуты и взаимоотношения указаны другими парами имя/значение в том же объекте JSON. "self" это имя, зарегистрированное в IANA для этой цели.

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

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

В предыдущем примере я использовал в ссылках относительную форму записи URI, например, /people/98765. Возможно, клиенту было бы немного удобнее (хотя, автору при форматировании этого поста было не слишком сподручно), если бы я выразил URI в абсолютной форме, напр. pets.org/people/98765. Клиентам необходимо знать лишь стандартные правила URI, определенные в спецификациях IETF, чтобы преобразовывать такие URI из одной формы в другую, поэтому выбор конкретной формы URI не так важен, как могло бы показаться на первый взгляд. Сравните эту ситуацию с описанным выше преобразованием из внешнего ключа в URL, для чего требовались конкретные знания об API зоомагазина. Относительные URL несколько удобнее для тех, кто занимается реализацией сервера, о чем рассказано ниже, но абсолютные URL, пожалуй, удобнее для большинства клиентов. Возможно, именно поэтому в API Google Drive и GitHub используются абсолютные URL.

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

Подводные камни


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

Перед многими реализациями API поставлены обратные прокси, обеспечивающие безопасность, балансировку нагрузки и пр. Некоторые прокси любят переписывать URL. Когда в API для представления взаимоотношений используются внешние ключи, единственный URL, который требуется переписать в прокси это главный URL запроса. В HTTP этот URL разделяется между адресной строкой (первая строка заголовка) и заголовком хоста.

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

  1. Не переписывайте URL в прокси. Я стараюсь избегать переписывания URL, но в вашей среде может не быть такой возможности.
  2. В прокси аккуратно найдите и переназначьте им формат везде, где они фигурируют в запросе или в отклике. Я так никогда не делал, поскольку мне это кажется сложным, чреватым ошибками и неэффективным, но кто-то, возможно, так поступает.
  3. Записывайте все ссылки в относительном виде. Можно не только встроить во все прокси некоторые возможности по перезаписи URL; более того, относительные URL могут упростить использование одного и того же кода в тестировании и в продакшене, так как код не придется конфигурировать и знать для этого его хост-имя. Если писать ссылки с использованием относительных URL, то есть, с единственным ведущим слэшем, как я показал в примере выше, то возникают некоторые минусы как для сервера, так и для клиента. Но в таком случае в прокси появляется лишь возможность сменить хост-имя (точнее, те части URL, которые называются схемой и источником), но не путь. В зависимости от того, как построены ваши URL, вы можете реализовать в прокси некоторую возможность переписывать пути, если готовы писать ссылки с использованием относительных URL без ведущих слэшей, но я так никогда не делал, поскольку полагаю, что серверам будет сложно записывать такие URL как следует.


Относительными URL без ведущих слэшей также сложнее пользоваться клиентам, поскольку они должны работать со стандартизированной библиотекой, а не обходиться простым сцеплением строк для обработки этих URL, а также тщательно понимать и сохранять базовый URL.

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

/v1/pets/12345
/v2/pets/12345
/v1/people/98765
/v2/people/98765


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

Эта ситуация весьма напоминает возможность просмотра одного и того же документа на нескольких естественных языках, для чего существует веб-стандарт; какая жалость, что нет подобного стандарта для версий. Присваивая каждой версии собственный URL, вы повышаете каждую версию по статусу до полноценного веб-ресурса. Нет ничего дурного с версионными URL такого рода, но они не подходят для выражения ссылок. Если клиент запрашивает Лесси в формате версии 2, это не означает, что он также хочет получить в формате 2 и информацию о Джо, хозяине Лесси, поэтому сервер не может выбрать, какой номер версии поставить в ссылку.

Возможно, формат 2 для описания владельцев даже не будет предусмотрен. Также нет концептуального смысла в том, чтобы использовать в ссылках конкретную версию URL ведь Лесси принадлежит не конкретной версии Джо, а Джо как таковому. Поэтому, даже если вы предоставляете URL в формате /v1/people/98765 и идентифицируете таким образом конкретную версию Джо, то также должны предоставлять URL /people/98765 для идентификации самого Джо, и именно второй вариант использовать в ссылках. Другой вариант определить только URL /people/98765 и позволить клиентам выбирать конкретную версию, включая для этого заголовок запроса. Для этого заголовка нет никакого стандарта, но, если называть его Accept-Version, то такой вариант хорошо сочетается с именованием стандартных заголовков. Лично я предпочитаю использовать для версионирования заголовок и избегаю ставить в URL номера версий. но URL с номерами версий популярны, и я часто реализую и заголовок. и версионные URL, так как легче реализовать оба варианта, чем спорить, какой лучше. Подробнее о версионировании API можете почитать в этой статье.

Возможно, вам все равно потребуется документировать некоторые шаблоны URL


В большинстве веб-API URL нового ресурса выделяется сервером, когда новый ресурс создается при помощи метода POST. Если вы пользуетесь этим методом для создания ресурсов и указываете взаимоотношения при помощи ссылок, то вам не требуется публиковать шаблон для URI этих ресурсов. Однако, некоторые API позволяют клиенту контролировать URL нового ресурса. Позволяя клиентам контролировать URL новых ресурсов, мы значительно упрощаем многие паттерны написания скриптов API разработчикам клиентской части, а также поддерживаем сценарии, в которых API используется для синхронизации информационной модели с внешним источником информации. В HTTP для этой цели предусмотрен специальный метод: PUT. PUT означает создай ресурс по этому URL, если он еще не существует, а если он существует обнови его. Если ваш API позволяет клиентам создавать новые сущности при помощи метода PUT, то вы должны документировать правила составления новых URL, возможно, включив для этого шаблон URI в спецификацию API. Также можно предоставить клиентам частичный контроль над URL, включив первичное ключ-подобное значение в тело или заголовки POST. В таком случае не требуется шаблон URI для POST как такового, но клиенту все равно придется выучить шаблон URI, чтобы полноценно пользоваться достигаемой в результате предсказуемостью URI.

Другой контекст, в котором целесообразно документировать шаблоны URL это случай, когда API позволяет клиентам кодировать запросы в URL. Не каждый API позволяет вам запрашивать свои ресурсы, но такая возможность может быть очень полезна для клиентов, и естественно позволить клиентам кодировать запросы в URL и извлекать результаты методом GET. В следующем примере показано, почему.

В вышеприведенном примере мы включили следующую пару имя/значение в представление Джо:

"pets": "/pets?owner=/people/98765"

Клиенту, чтобы пользоваться этим URL, не требуется что-либо знать о его структуре кроме того, что он был записан в соответствии со стандартными спецификациями. Таким образом, клиент может получить по этой ссылке список питомцев Джо, не изучая для этого никакой язык запросов. Также отсутствует необходимость документировать в API форматы его URL но только в случае, если клиент сначала сделает запрос GET к /people/98765. Если же, кроме того, в API зоомагазина документирована возможность выполнения запросов, то клиент может составить такой же или эквивалентный URL запроса, чтобы извлечь питомцев интересующего его владельца, не извлекая перед этим самого владельца достаточно будет знать URI владельца. Возможно, даже важнее, что клиент может формировать и запросы, подобные следующим, что в ином случае было бы невозможно:

/pets?owner=/people/98765&species=Dog
/pets?species=Dog&breed=Collie


Спецификация URI описывает для этой цели часть HTTP URL, называемую "компонент запроса" это участок URL после первого ? и до первого #. Стиль запрашивания URI, который я предпочитаю использовать всегда ставить клиент-специфичные запросы в компонент запроса URI. Но при этом допустимо выражать клиентские запросы и в той части URL, которая называется путь. Так или иначе, необходимо описать клиентам, как составляются эти URL вы фактически проектируете и документируете язык запросов, специфичный для вашего API. Разумеется, также можно разрешить клиентам ставить запросы в теле сообщения, а не в URL, и пользоваться методом POST, а не GET. Поскольку существует практический лимит по размеру URL превышая 4k байт, вы всякий раз испытываете судьбу рекомендуется поддерживать POST для запросов, даже если вы уже поддерживаете GET.

Поскольку запросы такая полезная возможность в API, и поскольку проектировать и реализовывать языки запросов непросто, появились такие технологии, как GraphQL. Я никогда не пользовался GraphQL, поэтому не могу рекомендовать его, но вы можете рассмотреть его в качестве альтернативы для реализации возможности запросов в вашем API. Инструменты для реализации запросов в API, в том числе, GraphQL, лучше всего использовать в качестве дополнения к стандартному HTTP API для считывания и записи ресурсов, а не как альтернативу HTTP.

И кстати Как лучше всего писать ссылки в JSON?


В JSON, в отличие от HTML, нет встроенного механизма для выражения ссылок. Многие по-своему понимают, как ссылки должны выражаться в JSON, и некоторые подобные мнения публиковались в более или менее официальных документах, но в настоящее время нет стандартов, ратифицированных авторитетными организациями, которые бы это регламентировали. В вышеприведенном примере я выражал ссылки при помощи обычных пар имя/значение, написанных на JSON предпочитаю такой стиль и, кстати, этот же стиль используется в Google Drive и GitHub. Другой стиль, который вам, вероятно, встретится, таков:
  {"self": "/pets/12345", "name": "Lassie", "links": [   {"rel": "owner" ,    "href": "/people/98765"   } ]}

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

Есть и другой стиль написания ссылок на JSON, который мне нравится, и он выглядит так:
 {"self": "/pets/12345", "name": "Lassie", "owner": {"self": "/people/98765"}}


Польза этого стиля в том, что он явно дает: "/people/98765" это URL, а не просто строка. Я изучил этот паттерн по RDF/JSON. Одна из причин освоить этот паттерн вам так или иначе придется им пользоваться, всякий раз, когда вы захотите отобразить информацию об одном ресурсе, вложенную в другом ресурсе, как показано в следующем примере. Если использовать этот паттерн повсюду, код приобретает красивое единообразие:

{"self": "/pets?owner=/people/98765", "type": "Collection",  "contents": [   {"self": "/pets/12345",    "name": "Lassie",    "owner": {"self": "/people/98765"}   } ]}


Более подробно о том, как лучше всего использовать JSON для представления данных, рассказано в статье Terrifically Simple JSON.

Наконец, в чем же разница между атрибутом и взаимоотношением?


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

{"self": "/people/98765", "shoeSize": 10}

Принято считать, что shoeSize это атрибут, а не взаимоотношение, а 10 это значение, а не сущность. Правда, не менее логично утверждать, что строка '10 фактически является ссылкой, записанной специальной нотацией, предназначенной для ссылок на числа, до 11-го целого числа, которое само по себе является сущностью. Если 11-е целое число совершенно полноценная сущность, а строка '10' лишь указывает на нее, то пара имя/значение '"shoeSize": 10' концептуально является ссылкой, хотя, здесь и не используются URI.

То же можно утверждать и относительно булевых значений, и строк, поэтому все пары имя/значение в JSON можно трактовать как ссылки. Если такое восприятие JSON кажется вам оправданным, то естественно использовать простые пары имя/значение в JSON как ссылки на те сущности, на которые также можно указывать при помощи URL.

В более общем виде этот аргумент формулируется как нет фундаментальной разницы между атрибутами и взаимоотношениями. Атрибуты это просто взаимоотношения между сущностью или другой абстрактной или конкретной сущностью, например числом или цветом. Но исторически сложилось, что к их обработке относились по-особенному. Признаться, это достаточно абстрактный вариант восприятия мира. Так, если вы покажете кому-либо черного кота и спросите, сколько тут объектов большинство людей ответит вам, что всего один. Немногие скажут, что видят два объекта кота и его черный цвет а также взаимоотношение между ними.

Ссылки попросту лучше


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

Telegram бот на Firebase

26.04.2021 14:21:23 | Автор: admin

В основном, про Firebase рассказывают в контексте создания приложений под IOS или Android. Однако, данный инструмент можно использовать и в других областях разработки, например при создании Telegram ботов. В этой статье хочу рассказать и показать насколько Firebase простой и удобный инструмент (а ещё и бесплатный, при разумных размерах проекта).


Motivation

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

В середине февраля я с ребятами из веб студии обсуждал идею создания приложения по подбору квартир с рекомендательной системой, которая анализировала бы изображения интерьеров и подстраивалась под предпочтения пользователя. Так как мой диплом должен быть на тему Computer Vision, то я решил развить эту тему. Да, и было придумало прикольное название - Flinder (Flats Tinder).

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

В частности, меня вдохновила одна научная статья про DeViSE: A Deep Visual-Semantic Embedding Model. Мне было интересно попробовать такие эмбединги.

В чём суть?

Если кратко, то авторы статьи обучили нейронную сеть предсказывать не конкретные классы изображений, по типу "кошка", "собака", а векторные представления названий классов. Это те самые векторные представления, для которых "King - Man + Woman = Queen".

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

Какого бота я делал?

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

Итак, телеграм бот:

  • Присылает пользователю изображение и просит его оценить

  • Получает оценку от пользователя

  • Сохраняет оценку пользователя в базу данных

  • *киллер фича* - удаляет изображение из диалога, если оно не понравилось пользователю

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

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

Изображения добывал с помощью библиотечки pinterest-image-scraper (Там есть баги и она не супер удобная, но мне её хватило).

Firebase

Меня немного смущал момент отправки изображений телеграм ботом. Получившаяся база изображений в 20.000 штук весила примерно 1.5 гигабайта и мучаться с переносом её на сервер мне уж совсем не хотелось.

Тут я подумал о том, что было бы классно выложить все фотографии на какой-нибудь облачный сервис, и дальше в телеграмм боте использовать только ссылки на изображения, а не сами изображения. А ссылки на изображения (или идентификаторы изображений) и оценки пользователей можно хранить в Firebase Realtime Database.

Перед этим я быстро проверил, поддерживает ли библиотека pyTelegramBotAPI отправку изображений ссылками. Метод send_photo спокойно работал с ссылками на изображения, так что я решил дальше использовать этот подход.

Прежде я работал только с Firebase Realtime Database, но про удобство Firebase Storage был наслышан.

Инициализация проекта в Firebase

Итак, чтобы начать работать с Firebase вам необходимо зарегистрироваться на этом сервисе и создать там проект. После чего у вас откроется вкладка Project Overview.

Project OverviewProject Overview

Для того чтобы получить доступ к функциям Firebase из кода необходимо скачать ключи доступа к проекту. Сделать это можно нажав на значок шестерёнки в верхнем левом углу, справа от надписи Project Overview, и выбрать пункт Project Settings. Затем, на открывшемся экране нужно выбрать Service Accounts и нажать Generate new private key.

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

import firebase_adminfrom firebase_admin import credentialsfrom firebase_admin import dbfrom firebase_admin import storagecred = credentials.Certificate("/path/to/secret/key.json")default_app = firebase_admin.initialize_app(cred, {  'databaseURL': 'https://realtime-db-name',    'storageBucket' : 'storage-bucket-local-name'})bucket = storage.bucket()

Где правые части внутри выражения initialize_app есть условные ссылки на названия ваших баз данных внутри проекта в Firebase. После инициализации у вас будут доступны две базы данных

  • db - объект Realtime Database. Данные хранятся в виде одного JSON дерева. В случае работы с питоном - это по сути объект dict.

  • bucket - объект Storage, по сути, обёртка над Google Storage, позволяющая по API загружать и скачивать объекты.

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

Firebase Realtime Database

Обожаю эту базу данных и готов петь ей дифирамбы. Она очень удобная, быстрая, надёжная, а главное - никакого SQL! Это JSON based Database. Но хватит похвалы, давайте посмотрим, как с ней работать.

Например, у нас есть несколько пользователей, которые хранятся в users_database.

users_databse = {"1274981264" : {"username" : "user_1","last_activity" : 1619212557},"4254785764" : {"username" : "user_2","last_activity" : 1603212638}}

Добавить их в в Realtime Database мы можем так:

db.reference("/users_databse/").set(users_databse)

Также мы можем добавить и следующего пользователя таким вот образом:

user_3_id = "2148172489"user_3 = {"username" : "user_3","last_activity" : 1603212638}db.reference("/users_database/" + user_3_id).set(user_3)

Этот код добавит user_3 в users_database

Получить данные можно так.

user_3 = db.reference("/users_database/" + user_3_id).get()users_databse = db.reference("/users_databse/").get()

Это вернет объекты формата Python dict

Стоит отметить, что массивы в Realtime database хранятся в следующем виде.

a = ["one", "two", "three"]firebase_a = {"0" : "one","1" : "two","2" : "three"}

То есть также в формате json

И ещё один нюанс, Realtime Database не хранит объекты None и [] То есть код

db.reference("/users_database/" + user_3_id).set(None)

Приведёт к ошибке

А код

db.reference("/users_database/" + user_3_id).set([])

Удалит данные user_3

Также стоит добавить, что если внутри вашего объекта в питоне есть какое-либо поле, значение которого есть None или [], то в объекте, загруженном в Realtime Database этих полей не будет. То есть:

user_4 = {"username" : "user_4","last_activity" : 4570211234,  "interactions" : []}# Но user_4_in_fb = {"username" : "user_4","last_activity" : 4570211234}

На самом деле, методами get() и set() всё не ограничивается. По ссылке вы можете посмотреть документацию по firebase_admin.db

Firebase Storage

Вернёмся к Firebase Storage. Допустим, у нас на локальном диске хранится изображение по пути image_path Следующий код добавит это изображение в Storage.

def add_image_to_storage(image_path):    with open(image_path, "rb") as f:        image_data = f.read()    image_id = str(uuid.uuid4())        blob = bucket.blob(image_id + ".jpg")        blob.upload_from_string(        image_data,        content_type='image/jpg'    )

Где image_id - уникальный идентификатор изображения.

С получением доступа к изображению всё чуточку сложнее. blob имеет формат

blob.__dict__
blob.__dict__ = {'name': 'one.jpg', '_properties': {'kind': 'storage#object',  'id': 'flinder-interiors/one.jpg/1619134548019743',  'selfLink': 'https://www.googleapis.com/storage/v1/b/flinder-interiors/o/one.jpg',  'mediaLink': 'https://storage.googleapis.com/download/storage/v1/b/flinder-interiors/o/one.jpg?generation=1619134548019743&alt=media',  'name': 'one.jpg',  'bucket': 'flinder-interiors',  'generation': '1619134548019743',  'metageneration': '1',  'contentType': 'image/jpg',  'storageClass': 'REGIONAL',  'size': '78626',  'md5Hash': 'OyY/IkYwU3R1PlYxeay5Jg==',  'crc32c': 'VfM6iA==',  'etag': 'CJ+U0JyCk/ACEAE=',  'timeCreated': '2021-04-22T23:35:48.020Z',  'updated': '2021-04-22T23:35:48.020Z',  'timeStorageClassUpdated': '2021-04-22T23:35:48.020Z'}, '_changes': set(), '_chunk_size': None, '_bucket': <Bucket: flinder-interiors>, '_acl': <google.cloud.storage.acl.ObjectACL at 0x7feb294ff410>, '_encryption_key': None}

Где есть selfLink и mediaLink, однако доступ к изображению по этим ссылкам - ограничен и доступен только при наличии определенных прав доступа, которые настраиваются в консоли Firebase.

В своём проекте я постарался сделать всё максимально просто и поэтому воспользовался методом blob.generate_signed_url(...). Этот метод генерирует ссылку, которая имеет определённое время жизни. Время жизни ссылки является параметром метода.

Следующий метод генерирует ссылку, живущую 10 минут.

def get_image_link_from_id(image_id):    blob = bucket.blob(image_id + ".jpg")    time_now = int(time.time() // 1)    ttl = 600    return blob.generate_signed_url(time_now + ttl)

Telegram Bot

Не буду вдаваться в подробности написания телеграм ботов, так как на эту тему статей много (простой туториал, супер подробная статья). Пройдусь только по основным моментам.

Как выглядит бот?

В своём проекте на pyTelegramBotAPI я использовал InlineKeyboardButton, состоящую из эмоджи и callback_query_handler, обрабатывающий нажатия на кнопки.

keyboard = types.InlineKeyboardMarkup(row_width = 3)nott = types.InlineKeyboardButton(text="no_emoji", callback_data='no')bad = types.InlineKeyboardButton(text="bad_emoji", callback_data='bad')yes = types.InlineKeyboardButton(text="yes_emoji", callback_data='yes')keyboard.add(nott, bad, yes)
Небольшой баг хабра

Пока писал статью столкнулся с тем, что редактор статей Хабр в браузере Safari не переваривает эмоджи внутри вставок с кодом. Если что, в моём боте кнопки имеют такой вот вид, ниже скрин кода.

Реакции пользователей я храню в Realtime Database. Добавляю их туда следующим образом.

def push_user_reaction(chat_id, image_id, reaction):  db_path = "/users/" + str(chat_id)+ "/interactions/"+ str(image_id)db.reference(db_path).set(reaction)

База данных Firebase Realtime Database имеет следующий вид

users - база данных пользователей.

Для каждого пользователя в разделе interactions мы храним взаимодействия пользователя с изображениями. last_image_id и last_message_id - элементы логики работы телеграмм бота. Что-то типо конечного автомата.

Да, и идентификаторы пользователей в базе данных - это telegram id пользователей (chat_id для библиотеки telebot).

usersusersinteractionsinteractions

interiors_images - база идентификаторов изображений. В ней хранятся метаданные изображений, а также дублируются реакции пользователей.

interiors_imagesinteriors_images

Ну и images_uuids в Realtime Database - это просто массив с уникальными идентификаторами изображений. Своего рода костыль, чтобы было откуда выбирать идентификаторы изображений.

Костыль

Собственно говоря сам костыль.

IMAGES_UUIDS = Nonedef obtain_images_uuids():    global IMAGES_UUIDS    IMAGES_UUIDS = db.reference("/images_uuids/data").get()obtain_images_uuids()def get_random_image_id():    image_id = np.random.choice(IMAGES_UUIDS)    return image_id

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

Firebase Storage выглядит таким вот образом. Можно заметить, что названия изображений в Storage есть просто идентификаторы изображений + их расширение.

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

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

Деплой на сервер

Последнее время я стал адептом докера, поэтому и этот свой проект я размещал на сервер с помощью докера. Пройдусь по основным моментам, которые я использовал в проекте.

Dockerfile

FROM python:busterCOPY requirements.txt /tmp/RUN pip install -r /tmp/requirements.txtRUN mkdir /srcWORKDIR /srcCOPY ./src .CMD python3 /src/code/bot.py
requirements.txt
pyTelegramBotAPIfirebase-admingoogle-cloud-storagenumpy

Где src - это место монтирования docker volume, который я создал до этого командой

docker volume create \            --opt type=none \            --opt o=bind \            --opt device=/home/ubuntu/Flinder/src \            --name flinder_volume

После чего собрал образ и запустил контейнер следующим образом, где флаг-v монтирует созданные ранее flinder_volume в директорию src внутри докер контейнера.

docker run -d \--network=host \--name flinder_bot \--restart always \-v "flinder_volume:/src" devoak/flinder:1.0

Ну и полезное замечание, что у команды docker run можно указать прекрасный параметр --restart always, который обеспечит постоянную работу бота на сервере.

До этого я делал через systemctl, что было сложнее и менее удобно.

Заключение и капелька пиара

Flinder - именно так называется мой проект (Flats Tinder)Flinder - именно так называется мой проект (Flats Tinder)

Надеюсь, моя статья была полезна. Хочу привнести в сообщество программистов такую идею, что работать с Firebase - это очень просто, приятно и удобно, а главное - бесплатно, при разумных размерах проекта.

Более того, использование Firebase не ограничивается Телеграм ботами, недавно я сделал целый промышленный парсер инстаграмма на основе Firebase Realtime Database, о чём я тоже планирую написать статью.

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

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

Подробнее..

Гугл финанс перестал транслировать данные российских акций что делать?

15.06.2021 06:21:37 | Автор: admin

С 5 июня 2021 года сайт гугла, и самое главное гугл таблицы - перестали отдавать данные с Московской биржи.

При попытке получить котировки с префиксом MCX, например для Сбербанка, формулой из гугл таблиц =GOOGLEFINANCE("MCX:SBER") теперь всегда возвращается результат #N/A.

А при поиске любой российской бумаги на сайте Google находятся все рынки, кроме Московской биржи:

Попытка поиска котировки Sberbank of Russia на сайте https://www.google.com/finance/quote/MCX:SBER Попытка поиска котировки Sberbank of Russia на сайте https://www.google.com/finance/quote/MCX:SBER

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

Копирование формул из таблицы-примера в ваши собственные таблицы

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

Если после копирования из таблицы-примера в вашу таблицу формула не работает, то проверьте региональные настройки вашей таблицы.

Моя таблица с примером получения данных с Московской биржиМоя таблица с примером получения данных с Московской биржи

Я использую регион Соединенные Штаты, а если по умолчанию ваш регион Россия, то формулы корректно НЕ копируются!

Вот подробная инструкция как проверить региональные настройки конкретной таблицы:

  • Откройте файл в Google Таблицах на компьютере.

  • Нажмите Файл затем Настройки таблицы.

  • Выберите нужные варианты в разделах "Региональные настройки".

  • Нажмите Сохранить настройки.

    Как изменить региональные настройки и параметры расчетовКак изменить региональные настройки и параметры расчетов

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

Получение названий акций и облигаций

Гугл таблица с примерами автоматического получения имени для разных классов активовГугл таблица с примерами автоматического получения имени для разных классов активов

Можно получать названия акций и облигаций используя сервера Московской Биржи вместо переставшего работать гугл финанса. Сама формула при этом выглядит следующим образом:

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B3 = "TQOB",            B3 = "EQOB",            B3 = "TQOD",            B3 = "TQCB",            B3 = "EQQI",            B3 = "TQIR"        ),        "bonds",                 or(            B3 = "TQTF",            B3 = "TQBR",            B3 = "SNDX",            B3 = "TQIF"        ),        "shares"    )  & "/boards/" & B3 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,SECNAME",      "//row[@SECID='" & A3 & "']/@SECNAME")

Получение цен акций и облигаций

Гугл таблица с примерами автоматического получения цен акций и облигацийГугл таблица с примерами автоматического получения цен акций и облигаций

Можно получать цены акций и облигаций используя сервера Московской Биржи вместо переставшего работать гугл финанса. Сама формула при этом выглядит следующим образом:

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B10 = "TQOB",            B10 = "EQOB",            B10 = "TQOD",            B10 = "TQCB",            B10 = "EQQI",            B10 = "TQIR"        ),        "bonds",                 or(            B10 = "TQTF",            B10 = "TQBR",            B10 = "SNDX",            B10 = "TQIF"        ),        "shares"    )  & "/boards/" & B10 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,PREVADMITTEDQUOTE",      "//row[@SECID='" & A10 & "']/@PREVADMITTEDQUOTE")

Получение даты и значения дивиденда для акций

Гугл таблица с примерами автоматического получения дат и значений дивидендов для акций Гугл таблица с примерами автоматического получения дат и значений дивидендов для акций

Можно получать даты и значения дивидендов для акций, используя сервера Московской Биржи. У гугл финанса такого функционала никогда не было для российских акций. Сама формула при этом выглядит следующим образом:

=iferror(     INDEX(         IMPORTxml(            "http://iss.moex.com/iss/securities/" & A22 & "/dividends.xml?iss.meta=off",            "//row[@secid='" & A22 & "']/@value"        )  ,         ROWS(            IMPORTxml(                "http://iss.moex.com/iss/securities/" & A22 & "/dividends.xml?iss.meta=off",                "//row[@secid='" & A22 & "']/@value"            )        )  ,        1    )  ,    "нет")

Получение даты купона и значения для облигаций

Гугл таблица с примерами автоматического получения дат купонов и значений для облигацийГугл таблица с примерами автоматического получения дат купонов и значений для облигаций

Можно получать даты и значения купонов для облигаций, используя сервера Московской Биржи. У гугл финанса такого функционала никогда не было для российских облигаций и вообще облигаций в сервисе нет. Сама формула при этом выглядит следующим образом:

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B12 = "TQOB",            B12 = "EQOB",            B12 = "TQOD",            B12 = "TQCB",            B12 = "EQQI",            B12 = "TQIR"        ),        "bonds",                 or(            B12 = "TQTF",            B12 = "TQBR",            B12 = "SNDX",            B12 = "TQIF"        ),        "shares"    )  & "/boards/" & B12 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,NEXTCOUPON,COUPONVALUE",      "//row[@SECID='" & A17 & "']/@COUPONVALUE")

Получение даты оферты

Гугл таблица с примерами автоматического получения дат оферт для облигацийГугл таблица с примерами автоматического получения дат оферт для облигаций

Можно получать оферты облигаций, используя сервера Московской Биржи. У гугл финанса такого функционала никогда не было для российских облигаций и вообще облигаций в сервисе нет. Сама формула при этом выглядит следующим образом:

=IFNA(     IMPORTxml(        "https://iss.moex.com/iss/engines/stock/markets/" &          IFS(                         or(                B27 = "TQOB",                B27 = "EQOB",                B27 = "TQOD",                B27 = "TQCB",                B27 = "EQQI",                B27 = "TQIR"            ),            "bonds",                         or(                B27 = "TQTF",                B27 = "TQBR",                B27 = "SNDX",                B27 = "TQIF"            ),            "shares"        )  & "/boards/" & B27 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,OFFERDATE",          "//row[@SECID='" & A27 & "']/@OFFERDATE"    )  ,    "нет")

Источник данных

Если вы хотите разобраться во всех нюансах работы - откуда берутся данные, то вы, также как и я можете обратиться к официальной документации к информационно-статистическому серверу Московской Биржи (ИСС / ISS). Правда, изучая этот документ, вы можете обнаружить что большая часть интересных функций, приведенная в этой статье, в документе никак не отображена.

Итоги

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

Как и в случае c сервисом Google Финансы, который перестал выдавать российские результаты мы видим что можно использовать API Московской биржи, которое предоставляет широкие возможности.

Эти формулы работают только за счет API Московской биржи, с которой я никак не связан. Использую ИСС Мосбиржи только в личных информационных интересах.

Автор: Михаил Шардин,

15 июня 2021 г.

Подробнее..

Перевод Мониторинг качества воздуха c помощью данных TROPOMI в Google Earth Engine

01.07.2020 20:13:32 | Автор: admin


Доступ к воздуху, безопасному для дыхания, очень важен для планеты и её жителей. Однако сейчас во многих частях света люди и хрупкие экосистемы страдают от воздействия загрязнённой атмосферы. В одних только США плохое качество воздуха ежегодно становится причиной около 60,000 случаев преждевременной смерти и обходится государству более чем в 150 млн. долларов, которые тратятся на лечение связанных с этим недугов.


Сейчас, в период социального дистанцирования и перекрытых границ, во многих регионах происходит снижение выбросов загрязняющих веществ. Фактически мы наблюдаем новое состояние качества воздуха, связанное с отсутствием характерных выбросов от транспорта и иных источников. Атмосфера очищается, и спутники NASA и ESA регистрируют снижение концентрации NO2 над многими городами и транспортными коридорами.



Средняя континентальная концентрация диоксида азота в тропосфере, март 2019. Концентрация увеличивается вдоль градиента от пурпурного к жёлтому.


Контролируя качество воздуха, метеорологи могут прогнозировать и предупреждать периоды его ухудшения, когда людям следует оставаться внутри помещений. Кроме того, учёные отслеживают историю изменений качества воздуха, чтобы понять влияние антропогенных и природных процессов на выбросы загрязняющих веществв атмосферу. Для некоторых веществ такие изменения в концентрации фиксируются спутниками. Одним из устройств, которые собирают такие замеры, является прибор для изучения тропосферыTROPOMI (Tropospheric Monitoring Instrument),установленный на борту космического аппаратаSentinel-5 Precursor (S5P), который в настоящее время находится на орбите.


СпутникS5P был запущен в октябре 2017 года для обеспечения непрерывности сбора данных после вывода из эксплуатации аппаратов Envisat (ESA) и Aura (NASA), а также в преддверии запуска Sentinel-5. S5Pимеет на борту многоспектральный датчик TROPOMI, который регистрирует отражательную способность длин волн, взаимодействующих с различными составляющими атмосферы, включая аэрозоли, моноокисьуглерода, формальдегид, диоксид азота, озон, диоксид серы и метан. S5P также позволяет оценивать некоторые характеристики облачности. Цель этой статьи предоставить краткий обзор данных о выбросах, которые регистрирует TROPOMI, а также продемонстрировать возможности использования платформы Earth Engine для анализа и отображения этой информации. Приведённые ниже сведения следует рассматривать как общее руководство для практического использования данных и платформы, но не как источник выводов о последствиях социального дистанцирования и его влиянии на качество воздуха.


Атмосферные выбросы от лесных пожаров


Горение биомассы в результате пожара может привести к выбросу большого количества дымовых аэрозолей. Отслеживать перенос такого аэрозольного шлейфа в течение дней и даже недель позволяет ежедневная частота и глобальный охват измерений с S5P. На рисунке нижеанимация временного ряда изображений, отражающихциркуляцию аэрозолей, вызванных мощными пожарами австралийских кустарников 20192020 годов, которые в итоге повлияли на качество воздуха в городах Южной Америки. Результаты измерений УФ-аэрозольного индекса, которые использовались в этом случае, применяются и для отслеживания других аэрозольных выбросов, таких как песчаные бури и вулканический пепел.



Аэрозольный шлейф от мощных австралийских кустарниковых пожаров 20192020 годов, распространяющийся на восток через Тихий океан. Анимация по мотивам исследований Сары Апарисио (Sara Aparcio).


Анимация отличное средство визуализации данных, но для количественной оценки временных изменений в загрязнении воздуха зачастую целесообразно использовать растровую алгебру (image math). В следующем примере вычитаются данные на две даты, характеризующие концентрацию угарного газа (CO) до и во времяпожаров в лесах Амазонки в 2019 году. Цель выделить те регионы, где концентрация увеличилась в два и более раз, в результате чего Всемирная организация здравоохранения выпустила предупреждение о чрезмерном загрязнении воздуха.



Разница в концентрациях оксида углерода до и во время пожаров в Амазонке в 2019 году. На картах до (Before) и во время (During) концентрация увеличивается вдоль градиента от фиолетового к жёлтому, а для карты разница (Difference) от чёрного к белому (карта отмаскирована для выделения областей, в которых во время пожаров произошло как минимум удвоение концентрации угарного газа).


Антропогенные атмосферные выбросы


Сжигание ископаемого топлива для нужд промышленности, транспорта и генерации тепла способствует загрязнению воздуха. Слой с данными о концентрации диоксида азота (NO2) хорошо подходит для анализа подобных типов выбросов, поскольку этот газ имеет короткий срок существования, и, как следствие, регистрируется вблизи источника выбросов. К примеру, визуализируя плотность населения (Gridded Population of World dataset) и высокие концентрации NO2 относительно друг друга, можно выявить пространственную корреляцию между плотностью населения и концентрациями NO2 на восточном побережье США.



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


На сдвоенной карте сверху и на диаграмме снизу показано, что с увеличением плотности населения усиливается и концентрация NO2 (подробнеео построении графиков в Earth Engine читайте в соответствующем разделе документации).



Связь между плотностью населения и концентрацией тропосферного диоксида азота (NO2) в зимний период в США к востоку от р. Миссисипи. Интерполированные графики NO2 для среднего и межквартального диапазона представлены для интервалов плотности населения от 0 до 20,000 человек/км2 с шагом 2,000 человек/км2, где последний интервал представляет районы с плотностью более 20 000 человек / км2.


В настоящее время большая часть мира практикует социальное дистанцирование с целью снижения воздействия нового типа коронавируса. С уменьшением числа людей, которые ездят на работу, снижаются и атмосферные выбросы диоксида азота (см. интерпретацию от NASA). Использование данных TROPOMI и платформы Earth Engine позволяет учёным исследовать подобные взаимосвязи и закономерности практически в режиме реального времени, а также в региональном и глобальном масштабах. Один из пользователей, Кристина Вринчану (Cristina Vrinceanu), создала приложение Earth Engine, в котором реализован виджет-слайдер для визуализации снижения концентрации диоксида азота в регионах, находящихся на карантине. Так, в приложении Кристины и сопутствующей статье в Medium исследуется регион севера Италии, который в борьбе с распространением вируса применяет в том числе и карантинные ограничения.



Приложение Earth Engine демонстрирует применение виджета-слайдера для сравнения концентрации NO2 за два различных периода времени. (Приложение от Кристины Вринчану).


Ещё один наглядный способ визуализации изменений концентрации загрязняющих веществ в воздухе с течением времени это годовой график. Следующая диаграмма демонстрирует и этот подход, сравнивая по дням концентрацию диоксида азота в период с 2019 по 2020 год в северной Италии.



Среднегодовые временные ряды NO2 в сравнении со значениями концентрации 2020 года и 2019 года, представленные для Паданской низменности на севере Италии (включает Милан, Болонью и Венецию).


Важные дополнения


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


В некоторых регионах мира определённые сочетания экологии, климата, погоды, географии и выбросов приводят к вариациямконцентрации загрязняющих веществ. Так, для китайской провинции Хубэй характерны сезонные тренды концентраций NO2, что видно на следующем рисунке, на котором изображена серия наблюдений за последний 21 месяц, подкреплённая гармонической линией тренда. Линия трендаполезна для выделения регулярных сезонных колебаний, а также для обособления высокой дисперсии в зимние месяцы, вызваннойсменой погоды. Для того, чтобы не делать выводов на основе отдельных измерений, которые могут представлять аномальные наблюдения, связанные с погодой, рекомендуется использовать линии тренда и вычислять скользящие средние за недели или месяцы наблюдений. Ламсай и др (Lamsai et al., 2010) приводят подробный анализ сезонных тенденций в отношении NO2.



Концентрация NO2 в южной провинции Хэбэй, Китай. Точками представлены результаты измерений; линия представляет собой функцию гармонического тренда для иллюстрации сезонных колебаний.


Точно так же сезонные колебания характеры и для концентраций озона, что показано на следующем графике, который отражает фактические и гармонически интерполированные данные наблюдений атмосферы над районом Великих озёр в Соединённых Штатах (подробнее о гармоническом моделировании в Earth Engine читайте в соответствующем разделе документации).



Временные ряды концентрации озона для района Великих озер в США. Точками представлены результаты измерений; линия представляет собой функцию гармонического тренда для иллюстрации сезонных колебаний.


Важнейшим фактором является облачный покров, который может исказить результаты наблюдений, ограничив вклад нижней части атмосферы в полученные датчиком замеры. Ниже демонстрируется высокая изменчивость облачного покрова на пиксель изображения в районе северной Италии для продукта NO2. Обратите внимание на высокую долю облаковконце января 2019 года в сравнении с тем же периодом год спустя, когда облачный покров был намного меньше. В связи с этимпри сравнении разновременных наблюдений рекомендуется фильтровать данные таким образом, чтобы они включали только пиксели с низким облачным покровом. Подробная информация о свойствахоблачного покрова приведена в соответствующей статье программы Copernicus.



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


Приложение TROPOMI Explorer


Чтобы обеспечить пользователям удобство и оперативность при работе с данными S5P TROPOMI, а также предоставить платформу для дальнейшего анализа, специалисты Google создали интерактивное приложение, которое позволяет просматривать изменения концентрации загрязняющих веществ с течением времени, используя визуализацию в виде сдвоенных карт или карты-шторки, а также графики временных изменений. Приложение доступно по ссылке.



Интерактивное приложение для исследования данных TROPOMI, созданное с использованием Google Earth Engine Apps.


Хотя многие сейчас находятся на самоизоляции, сообщество пользователей и разработчиков платформы Earth Engine продолжает активно обмениваться идеями. Посмотрите, как другие анализируют и изучают данные S5P TROPOMI с помощью Earth Engine:



Пользователи Earth Engine мотивируют разработчиков развивать платформу и создавать новые инструменты для понимания нашего воздействия на планету, а также решения других глобальных проблем. Надеемся, что читателю понравился этот краткий обзор данных S5P TROPOMI. Начать самостоятельные эксперименты с данными S5P можно со знакомство с соответствующим каталогом данных в Earth Engine).


Перевод подготовлен преподавателями Инженерной академии Российского университета дружбы народов Василием Лобановым и Ярославом Васюниным.


Эта работа лицензируется в соответствии с Creative Commons Attribution 4.0 International License (CC BY 4.0)

Подробнее..

Разворачиваем сервер для проверки In-app purchase за 60 минут

12.11.2020 06:13:44 | Автор: admin

Всем привет! Сегодня расскажу вам как развернуть сервер для проверки In-app Purchase и In-app Subscription для iOS и Android (server-server validation).


На хабре есть статья от 2013 года про серверную проверку покупок. В статье говорится о том, что валидация в первую очередь необходима для предотвращения доступа к платному контенту при помощи джейлбрейка и другого софта. На мой взгляд в 2020 году эта проблема не так актуальна, и в первую очередь сервер с проверкой покупок необходима для синхронизации покупок в рамках одного аккаунта на нескольких устройствах


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




То есть задачу такого сервера можно разделить на 4 этапа:


  • Получение запроса с чеком, отправленным приложением после покупки
  • Запрос в Apple/Google на проверку чека
  • Сохранение данных о транзакции
  • Ответ приложению

В рамках статьи опустим 3 пункт, ибо он сугубо индивидуален.


Код в статье будет написан на Node.js, но по сути логика универсальна и не составит труда использовать ее написать валидацию на любом языке программирования.


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


Сразу скажу, что в сниппетах кода используются вспомогательные классы и интерфейсы, весь код доступен в репозитории по ссылке https://github.com/denjoygroup/inapppurchase. В приведенном ниже фрагментах кода, я постарался дать названия используемым методам такие, чтобы приходилось делать отсылки к этим функциям.


iOS


Для проверки вам нужен Apple Shared Secret это ключ, который вы должны получить в iTunnes Connect, он нужен для проверки чеков.


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


 apple: any = {    password: process.env.APPLE_SHARED_SECRET, // ключ, укажите свой    host: 'buy.itunes.apple.com',    sandbox: 'sandbox.itunes.apple.com',    path: '/verifyReceipt',    apiHost: 'api.appstoreconnect.apple.com',    pathToCheckSales: '/v1/salesReports' }

Теперь создадим функцию для отправки запроса. В зависимости от среды, с которой работаете, вы должны отправлять запрос либо на sandbox.itunes.apple.com для тестовых покупок, либо в прод buy.itunes.apple.com


/*** receiptValue - чек, который проверяете* sandBox - среда разработк**/async _verifyReceipt(receiptValue: string, sandBox: boolean) {    let options = {        host: sandBox ? this._constants.apple.sandbox : this._constants.apple.host,        path: this._constants.apple.path,        method: 'POST'    };    let body = {        'receipt-data': receiptValue,        'password': this._constants.apple.password    };    let result = null;    let stringResult = await this._handlerService.sendHttp(options, body, 'https');    result = JSON.parse(stringResult);    return result;}

Если запрос прошел успешно, то в ответе от сервера Apple в поле status вы получите данные о вашей покупке.


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


21000 Запрос был отправлен не методом POST


21002 Чек поврежден, не удалось его распарсить


21003 Некорректный чек, покупка не подтверждена


21004 Ваш Shared Secret некорректный или не соответствует чеку


21005 Сервер эпла не смог обработать ваш запрос, стоит попробовать еще раз


21006 Чек недействителен


21007 Чек из SandBox (тестовой среды), но был отправлен в prod


21008 Чек из прода, но был отправлен в тестовую среду


21009 Сервер эпла не смог обработать ваш запрос, стоит попробовать еще раз


21010 Аккаунт был удален


0 Покупка валидна


Пример ответа от iTunnes Connect выглядит следующим образом


{    "environment":"Production",    "receipt":{        "receipt_type":"Production",        "adam_id":1527458047,        "app_item_id":1527458047,        "bundle_id":"BUNDLE_ID",        "application_version":"0",        "download_id":34089715299389,        "version_external_identifier":838212484,        "receipt_creation_date":"2020-11-03 20:47:54 Etc/GMT",        "receipt_creation_date_ms":"1604436474000",        "receipt_creation_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",        "request_date":"2020-11-03 20:48:01 Etc/GMT",        "request_date_ms":"1604436481804",        "request_date_pst":"2020-11-03 12:48:01 America/Los_Angeles",        "original_purchase_date":"2020-10-26 19:24:19 Etc/GMT",        "original_purchase_date_ms":"1603740259000",        "original_purchase_date_pst":"2020-10-26 12:24:19 America/Los_Angeles",        "original_application_version":"0",        "in_app":[            {                "quantity":"1",                "product_id":"PRODUCT_ID",                "transaction_id":"140000855642848",                "original_transaction_id":"140000855642848",                "purchase_date":"2020-11-03 20:47:53 Etc/GMT",                "purchase_date_ms":"1604436473000",                "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",                "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",                "original_purchase_date_ms":"1604436474000",                "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",                "expires_date":"2020-12-03 20:47:53 Etc/GMT",                "expires_date_ms":"1607028473000",                "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",                "web_order_line_item_id":"140000337829668",                "is_trial_period":"false",                "is_in_intro_offer_period":"false"            }        ]    },    "latest_receipt_info":[        {            "quantity":"1",            "product_id":"PRODUCT_ID",            "transaction_id":"140000855642848",            "original_transaction_id":"140000855642848",            "purchase_date":"2020-11-03 20:47:53 Etc/GMT",            "purchase_date_ms":"1604436473000",            "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",            "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",            "original_purchase_date_ms":"1604436474000",            "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",            "expires_date":"2020-12-03 20:47:53 Etc/GMT",            "expires_date_ms":"1607028473000",            "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",            "web_order_line_item_id":"140000447829668",            "is_trial_period":"false",            "is_in_intro_offer_period":"false",            "subscription_group_identifier":"20675121"        }    ],    "latest_receipt":"RECEIPT",    "pending_renewal_info":[        {            "auto_renew_product_id":"PRODUCT_ID",            "original_transaction_id":"140000855642848",            "product_id":"PRODUCT_ID",            "auto_renew_status":"1"        }    ],    "status":0}

Также перед отправкой запроса и после отправки стоит сверить id продукта, который запрашивает клиент и который мы получаем в ответе.


Полезная для нас информация содержится в свойствах in_app и latest_receipt_info, и на первый взгляд содержимое этих свойств идентичны, но:


latest_receipt_info содержит все покупки.


in_app содержит Non-consumable и Non-Auto-Renewable покупки.


Будем использовать latest_receipt_info, соотвественно в этом массиве ищем нужный нам продукт по свойству product_id и проверяем дату, если это подписка. Конечно, стоит еще проверить не начислили ли мы уже эту покупку пользователю, особенно актуально для Consumable Purchase. Проверять можно по свойству original_transaction_id, заранее сохранив в базе, но в рамках этого гайдлайна мы этого делать не будем.


Тогда проверка покупки будет выглядеть примерно так


/*** product - id покупки* resultFromApple - ответ от Apple, полученный выше* productType - тип покупки (подписка, расходуемая или non-consumable)* sandBox - тестовая среда или нет***/async parseResponse(product: string, resultFromApple: any, productType: ProductType, sandBox: boolean) {    let parsedResult: IPurchaseParsedResultFromProvider = {        validated: false,        trial: false,        checked: false,        sandBox,        productType: productType,        lastResponseFromProvider: JSON.stringify(resultFromApple)    };    switch (resultFromApple.status) {        /**        * Валидная подписка        */        case 0: {            /**            * Ищем в ответе информацию о транзакции по запрашиваемому продукту            **/            let currentPurchaseFromApple = this.getCurrentPurchaseFromAppleResult(resultFromApple, product!, productType);            if (!currentPurchaseFromApple) break;            parsedResult.checked = true;            parsedResult.originalTransactionId = this.getTransactionIdFromAppleResponse(currentPurchaseFromApple);            if (productType === ProductType.Subscription) {                parsedResult.validated = (this.checkDateIsAfter(currentPurchaseFromApple.expires_date_ms)) ? true : false;                parsedResult.expiredAt = (this.checkDateIsValid(currentPurchaseFromApple.expires_date_ms)) ?                this.formatDate(currentPurchaseFromApple.expires_date_ms) : undefined;            } else {                parsedResult.validated = true;            }            parsedResult.trial = !!currentPurchaseFromApple.is_trial_period;            break;        }        default:            if (!resultFromApple) console.log('empty result from apple');            else console.log('incorrect result from apple, status:', resultFromApple.status);    }    return parsedResult;}

После этого можно возвращать ответ на клиент по нашей покупке, которая хранится в переменной parsedResult. Формировать структуру этого объекта вы можете по своему усмотрению, зависит от ваших потребностей, но самое главное, что на этом шаге мы уже знаем валидна покупка или нет, и информацию об этом хранится в parsedResult.validated.


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


Android


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


Для гугла нам понадобится чуть больше входных параметров:


google: any = {    host: 'androidpublisher.googleapis.com',    path: '/androidpublisher/v3/applications',    email: process.env.GOOGLE_EMAIL,    key: process.env.GOOGLE_KEY,    storeName: process.env.GOOGLE_STORE_NAME}

Получить эти данные можно воспользовавшись инструкцией по ссылке.


Окей, гугл, прими запрос:


/*** product - название продукта* token - чек* productType  тип покупки, подписка или нет**/async getPurchaseInfoFromGoogle(product: string, token: string, productType: ProductType) {    try {        let options = {            email: this._constants.google.email,            key: this._constants.google.key,            scopes: ['https://www.googleapis.com/auth/androidpublisher'],        };        const client = new JWT(options);        let productOrSubscriptionUrlPart = productType === ProductType.Subscription ? 'subscriptions' : 'products';        const url = `https://${this._constants.google.host}${this._constants.google.path}/${this._constants.google.storeName}/purchases/${productOrSubscriptionUrlPart}/${product}/tokens/${token}`;        const res = await client.request({ url });        return res.data as ResultFromGoogle;    } catch(e) {        return e as ErrorFromGoogle;    }}

Для авторизации воспользуемся библиотекой google-auth-library и класс JWT.


Ответ от гугла выглядит примерно так:


{    startTimeMillis: "1603956759767",    expiryTimeMillis: "1603966728908",    autoRenewing: false,    priceCurrencyCode: "RUB",    priceAmountMicros: "499000000",    countryCode: "RU",    developerPayload: {        "developerPayload":"",        "is_free_trial":false,        "has_introductory_price_trial":false,        "is_updated":false,        "accountId":""    },    cancelReason: 1,    orderId: "GPA.3335-9310-7555-53285..5",    purchaseType: 0,    acknowledgementState: 1,    kind: "androidpublisher#subscriptionPurchase"}

Теперь перейдем к проверке покупки



parseResponse(product: string, result: ResultFromGoogle | ErrorFromGoogle, type: ProductType) {    let parsedResult: IPurchaseParsedResultFromProvider = {        validated: false,        trial: false,        checked: true,        sandBox: false,        productType: type,        lastResponseFromProvider: JSON.stringify(result),    };    if (this.isResultFromGoogle(result)) {        if (this.isSubscriptionResult(result)) {            parsedResult.expiredAt = moment(result.expiryTimeMillis, 'x').toDate();            parsedResult.validated = this.checkDateIsAfter(parsedResult.expiredAt);        } else if (this.isProductResult(result)) {            parsedResult.validated = true;        }    }    return parsedResult;}

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


Итог


По существу буквально в 2 метода можно проверить покупку. Репозиторий с полным кодом доступен по ссылке https://github.com/denjoygroup/inapppurchase (автор кода Алексей Геворкян)


Конечно, мы упустили очень много нюансов обработки покупки, которые стоит учитывать при работе с реальными покупками.


Есть два хороших сервиса, которые предоставляют сервис для проверки чеков: https://ru.adapty.io/ и https://apphud.com/. Но, во-первых, для некоторых категорий приложений нельзя передавать данные 3 стороне, а во-вторых, если вы хотите отдавать платный контент динамически при совершении пользователем покупки, то вам придется разворачивать свой сервер.


P.S.


Ну, и, конечно, самое важное в серверной разработке это масштабируемость и устойчивость. Если у вас большая аудитория пользователей и при этом сервер не способен выдерживать нагрузки, то лучше и не реализовывать проверку покупок самим, а отправлять запросы сразу в iTunnes Connect и в Google API, иначе ваши пользователи сильно расстроятся.

Подробнее..

Из песочницы Создание системы антифрода в такси с нуля

28.07.2020 10:08:24 | Автор: admin

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


image


Введение


Кто раз умеет обмануть, тот много раз еще обманет.
Лопе де Вега

Фрод в нашем случае это ситуация, когда водитель обманывает компанию. Мошенничество с целью получения денег.


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


Постановка задачи и паттерны поведения


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

После консультаций с директорами городов, кураторами и менеджерами были выявлены самые частые схемы мошенничества:


  • Воровство комиссии. Водитель отменяет заказ (или просит по-братски отменить поездку пассажира), потом его выполняет, забирает у клиента наличку. В компанию не приходит ни копейки;
  • Фейковые заказы с целью получения доплат. Доплаты это сумма, которую агрегатор из своего кармана добавляет к выручке водителя за поездку. Тогда ему становится выгоднее брать непопулярные заказы (короткие и дешёвые, например). Водитель делает такой заказ сам себе или другу с левой сим-карты, потом проезжает 200 метров и получает незаслуженную денежку.
  • Более мелкие паттерны, которые мы объединили в один Подозрительные водители:
    • Водители, у которых отключена комиссия (скажем, менеджер города мог поставить 0% комиссии своему другу);
    • Водители, которые покупают безлимитный тариф и по одному аккаунту работают вдвоём-втроём.

image


Функции в нашей команде распределены так:


  • Аналитик отвечает за связь с менеджерами на местах, решает возникающие проблемы, продумывает критерии поиска, в свободное время пишет код;
  • Дата-аналитик (ваш покорный слуга) пишет код, придумывает как сделать так, чтобы всё работало автоматически и точно;
  • Директор по аналитике накидывает свои идеи, критикует чужие, ревьюит код, следит чтобы получался хороший продукт а не как в прошлый раз.

Сложности


  • Важность снижения как False Positive, так и False Negative ошибок. Человеческим языком:
    • Не хочется обвинять честного водителя, так как он нас покинет (и будет прав, чёрт возьми!);
    • Не хочется оставлять нарушителя безнаказанным.
  • Необходимость ручной проверки и человеческий фактор. На этом пункте мы ещё остановимся подробнее;
  • Водители. Да, сами водители это сложность для аналитика. Любая задача, связанная с ними, намного тяжелее аналогичной задачи, связанной с пассажирами. Занимались мы как-то предсказанием оттока и тех, и других Но это уже совсем другая история.

Процесс


image


Основную задачу выполняет SQL-запрос в DWH, который возвращает подозрительные поездки и водителей. Те должны обладать набором заранее рассчитанных признаков. Вот, например, как выглядит фильтрация по паттерну Доплаты:


WHERE susp = 1 -- Флаг на подозрительность  AND finished_orders >= 3 -- Три и более УСПЕШНЕ поездки с одним водителем  AND cancelled >= 3 -- Три и более водителя, с которыми у номера телефона были только ОТМЕН  AND dist_fin_drivers <= 2 -- Успешные поездки максимум с ДВУМЯ водителями  AND ok <= 2 -- Не больше 2-х УСПЕШНХ поездок с другими водителями

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


Еще пример. В паттерне Воровство комиссии ключевую роль играют координаты водителя. Отменил заказ, но отметился на всём его протяжении? Ну-ну. Считаем расстояния, добавляем ещё несколько важных фильтров и выгружаем такие поездки.


image


Далее в работу вступает скрипт на python. Он оборачивает выгрузку в pandas, сохраняет ее в postgres, преобразует в нужный вид и выгружает на проверку в листы Google (о них мы еще поговорим подробнее). Часть скрипта, выгружающая поездки, запускается автоматически дважды в день с помощью Apache Airflow.


Рассмотрим работу с API на примере.
Считываем креды и коннектимся:


credentials = ServiceAccountCredentials.from_json_keyfile_dict(    config.crd,    ['https://www.googleapis.com/auth/spreadsheets',     'https://www.googleapis.com/auth/drive'])httpAuth = credentials.authorize(httplib2.Http())service = googleapiclient.discovery.build('sheets', 'v4', http=httpAuth)sheet = service.spreadsheets()

Добавляем данные на лист:


base_range = f'{city_name}!A{ss_row + 1}:Z{ss_row + reserved_rows}'sheet.values().append(spreadsheetId=spreadsheetid,                                 range=base_range,                                 body={"values": df_pos.values.tolist()},                                 valueInputOption='RAW').execute()

Забираем резолюции:


range_from_ss = f'{city_name}!A{ss_row}:S{ss_row + reserved_rows}'data_from_ss = service.spreadsheets().values().get(            spreadsheetId=spreadsheetid,            range=range_from_ss).execute().get('values', [])data_from_ss = pd.DataFrame(data_from_ss)data_from_ss_cols = ['id', 'Резолюция', 'Комментарий']data_from_ss = data_from_ss.loc[1:, data_from_ss_cols]

Заносим их в PG:


vls_ss = ','.join([f"""({', '.join([f(d[c]) for c in data_from_ss_cols])}                    )""" for d in data_from_ss.to_dict('rows')])sql_update = f"""    WITH updated as (        UPDATE fraud_billing        SET resolution = tb.resolution,            comment=tb.comment,            dt = NOW()        FROM (VALUES {vls_ss}) AS tb(fraud_billing_id, resolution, comment)        WHERE fraud_billing.fraud_billing_id = CAST(tb.fraud_billing_id AS INTEGER)            AND ((fraud_billing.resolution IS NULL AND tb.resolution IS NOT NULL)                OR (fraud_billing.comment IS NULL AND tb.comment IS NOT NULL)                OR (fraud_billing.comment IS NOT NULL AND tb.comment IS NOT NULL                   AND fraud_billing.comment <> tb.comment)                OR (fraud_billing.resolution IS NOT NULL AND tb.resolution IS NOT NULL                    AND fraud_billing.resolution <> tb.resolution)               )        RETURNING {alias_cols_text_with_id}        )    INSERT INTO fraud_billing_history ({cols_text_with_id})    SELECT {cols_text_with_id}    FROM updated;"""crs_postgres.execute(sql_update)con_postgres.commit()

В самой postgres для каждого паттерна реализовано две таблицы:


  • хранение записей о поездках и водителях;
  • история обновлений.

Логи скрипта:
image


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


Пример того, как выглядит работа менеджера с листом:


image


Иногда по всем признакам сразу видно водитель фродил.


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


И так для каждого паттерна.


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


image


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


На картинке слева поймаем всех, но нужна дополнительная проверка, чтобы определить невиновных. Это наш выбор.


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


  • предупреждают;
  • штрафуют;
  • урезают в правах;
  • блокируют временно или навсегда.

На данном этапе мы лишь наблюдаем и логируем информацию.


Отмечу, что результат работы зависит от населённого пункта. Города различаются по населению, площади, уровню конкуренции, условиям для водителей, менеджменту. Для примера сравним кол-во подозрений и фрода за последние недели:


image


Как видите, подобрать универсальные правила и для Новосибирска, и для Магнитогорска нетривиальная задача.


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


  • Менеджеры городов хорошо в них разбираются, так как эксельку знают все;
  • У Гугла превосходный API удобный и лёгкий;
  • Они бесплатны;
  • Они обладают всем необходимым функционалом, в том числе контролем версий.

Самая большая проблема


Ручная проверка является важной частью системы антифрода. А там, где люди там ищи проблемы:


  • Самоуправство. Кто-то ведёт на листе расчёты, кто-то удаляет столбцы, кто-то придумывает свои виды резолюций. Мы ввели защиту на листы, но алгоритмы практически при каждом обновлении всё равно ругались на аномалии в данных. Пришлось написать дополнительные скрипты для проверки спрэдшитов перед обновлениями;
  • Необязательность. Проблема встречается не так часто, как предыдущая, но несёт намного больше вреда:
    • кто-то вообще не проверяет поездки, хотя их уже накопилось более тысячи;
    • кто-то не оставляет комментарии;
    • кто-то проставляет резолюции по своим внутренним ощущениям, без проверки;
    • кто-то не наказывает пойманных водителей.

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


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

Развитие и первые результаты


Как мы совершенствуем алгоритмы, то есть снижаем ошибки?


  • Невыявленные случаи фрода. Здесь нам помогают кураторы и менеджеры городов. Бывает, что они самостоятельно ловят фродовые поездки и водителей, которые пропустили наши скрипты. Мы выясняем причину и дорабатываем код;
  • Ложные обвинения. Таких ошибок гораздо больше, плюс их мы можем посчитать. Здесь помогают комментарии менеджеров к поездкам, которые они признали честными.

В начале нашего пути ошибка по самому масштабному паттерну (воровство комиссии) в среднем составляла около 35%. Сейчас меньше 25%. В то же время, по другому паттерну доплатам удалось не только свести ошибку к нулю, но и в десятки раз уменьшить количество таких случаев. Выдвинем гипотезу: водители поняли, что теперь за подобное наказывают, и решили, что риск не стоит свеч. И придумали другие схемы.


image


За первые месяцы работы удалось достичь следующих результатов:


  • 15 тысяч поездок признаны фродом;
  • 6800 водителей понесли наказание;
  • более 500 тысяч рублей вернулись в компанию только по воровству комиссий.

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


Заключение


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


В конце концов, мы лишь в начале пути.

Подробнее..

Перевод Приложение отвечает как мы уменьшили количество ANR-ошибок в шесть раз. Часть 1, про сбор данных

27.01.2021 18:04:51 | Автор: admin

Пожалуй, одна из худших проблем, которая может случиться с вашим приложением, ошибка ANR (Application Not Responding), когда приложение не отвечает. Если таких ошибок много, они могут негативно влиять не только на пользовательский опыт, но и на позицию в выдаче Google Play и фичеринг.

В начале прошлого года количество ANRs в приложении Badoo превышало порог Bad Behaviour в Google Play. Поэтому мы собрали команду для решения этой проблемы и потратили несколько месяцев, экспериментируя с разными подходами. В результате мы смогли уменьшить количество таких ошибок более чем в шесть раз.

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

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

Что такое ошибка ANR?

Обычно любое приложение с графическим интерфейсом выполняет все связанные с ним операции и отрисовку в отдельном UI-потоке исполнения. Android не исключение: здесь в главном потоке приложения выполняется цикл, отвечающий за все действия с интерфейсом:

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

Чтобы как-то идентифицировать такие ситуации, в Android ввели понятие ANR, с помощью которого система сообщает, что приложение зависло. Вот что об этом говорится в официальной документации:

Когда UI-поток Android-приложения блокируется слишком долго, выдаётся ошибка Application Not Responding (ANR).

ANR выдаётся, когда приложение находится в одном из этих состояний:

на переднем плане находится Activity, приложение в течение пяти секунд не отвечает на входящие события или BroadcastReceiver, например нажатия на кнопки или касания экрана;

на переднем плане нет Activity, ваш BroadcastReceiver не закончил исполнение в течение длительного времени.

Если ANR случается, когда на переднем плане находится Activity вашего приложения, Android показывает диалоговое окно с предложением закрыть приложение или подождать.

Довольно легко принудительно вызвать ANR, написав Thread.sleep() в любом обработчике интерфейса, например обработчик нажатия кнопки. После нажатия на кнопку вы увидите примерно следующее:

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

Чтобы снизить вероятность возникновения ANR, нужно всего лишь избегать выполнения длительных операций в главном потоке. Звучит вроде бы просто, но иногда не так легко определить корневую проблему, которая приводит к таким ошибкам. Поэтому довольно важно иметь хорошую систему мониторинга и репортинга ANR-ошибок.

Давайте посмотрим, какие существуют способы отладки ANR-ошибок и какие инструменты могут быть в этом полезны.

Отслеживание ANR

Локальный анализ

Самый простой случай если у вас есть возможность стабильно воспроизводить ANR-проблему локально. Существует довольно много инструментов, которые могут помочь вам быстро найти источник проблемы.

Первое, что можно сделать, это проверить дамп стек-трейсов для всех потоков (thread dump). Когда приложение перестает отвечать, Android создаёт дамп всех текущих потоков, который может помочь в анализе проблемы. Обычно он находится в директории /data/anr/, точный путь можно найти в Logcat сразу после сообщения об ошибке ANR.

Дамп потоков содержит стек-трейсы: вы увидите, в каком состоянии был каждый поток (например, какая строка выполнялась в конкретный момент времени). По сути, это состояние приложения на момент создания дампа.

Чаще всего причина возникновения ANR обнаруживается в стек-трейсе главного потока скорее всего, код в этом месте выполняется слишком долго. Если информации из этого стек-трейса будет недостаточно, можно попробовать обратиться к довольно неплохой документации от Google, где описываются основные причины, способы диагностирования и решения проблемы ANR.

Отслеживание с помощью Google Play

Google Play автоматически отправляет отчёты об ошибках ANR, если у пользователя включена такая опция. В консоли Google Play есть несколько метрик и инструментов для анализа ANR.

Во-первых, можно увидеть агрегированные графики с общим количеством ANR-ошибок за день. Также есть такая метрика, как ANR rate отношение количества сессий за день, в которых возникала хотя бы одна ANR-ошибка, к общему количеству сессий за сутки. Для этой метрики задан порог в 0,47%, превышение которого считается неудовлетворительным поведением (Bad Behaviour) и может плохо повлиять на позицию приложения в Google Play.

Во-вторых, можно открывать отдельные отчёты об ANR-ошибках, сгруппированные по схожести на основе стек-трейса. Основные группы находятся в разделе Android Vitals. И это, вероятно, наиболее полезный раздел для выявления самых частых причин возникновения ANR-ошибок в вашем приложении.

Если вы активно используете консоль Google Play, вы могли заметить некоторые недостатки. Например, к отчётам нельзя прикрепить дополнительную информацию, такую как логи для отладки. Также невозможно настроить логику группировки отчётов. Иногда система помещает в одну группу ошибки, возникшие по разным причинам, а иногда раскидывает по разным группам ошибки, у которых причина одна.

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

Скачивание данных из Google Play

Для решения проблемы с логикой группировки можно попробовать скачать сырые отчёты об ANR-ошибках из Google Play для последующего ручного анализа. Раньше была возможность выгрузить эти данные из Google Cloud Storage, но несколько лет назад Google перестала поддерживать этот функционал:

Однако всё ещё можно просматривать отдельные отчёты в консоли. Но как нам экспортировать тысячи отчётов, не потратив при этом кучу времени на рутинную работу?

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

Веб-скрапер можно реализовать с помощью популярного инструмента Selenium, который предоставляет простой интерфейс для взаимодействия с веб-страницами. Изначально он предназначался для создания автоматизированных тестов для веб-приложений и доступен на разных языках, включая Java и Kotlin.

Мы реализовали скрапер на Selenium и получили сырые отчёты об ANR-ошибках для одного из релизов. Благодаря этому нам удалось проанализировать их так, как не получилось бы сделать с помощью встроенных в консоль Google Play инструментов. Например, просто поискав в отчётах по ключевым словам Application.onCreate, мы обнаружили, что около 60% ошибок произошло во время выполнения метода Application.onCreate. При этом в консоли Google Play нет возможности получить такую информацию, так как отчёты разбиты по группам.

Внутренняя аналитика

Другой способ сбора дополнительных данных и проведения расширенного анализа заключается в настройке собственного репортинга ANR-ошибок. В прошлом мы уже экспериментировали с решением похожих проблем, настраивая репортинг крашей. Для того чтобы проводить анализ падений приложения, мы создали внутренний инструмент Gelato.

Его функциональность схожа с возможностями других инструментов для краш-репортинга, таких как Firebase Crashlytics и App Center, но ещё и позволяет нам полностью контролировать сохраняемые данные, менять логику группировки и применять сложную фильтрацию:

Это не реальные данные приложения Bumble, иллюстрация сделана просто для примераЭто не реальные данные приложения Bumble, иллюстрация сделана просто для примера

Мы решили отслеживать в Gelato ещё и ANR-ошибки в надежде, что это поможет нам в поиске их причин. Для этого нам нужно было знать, когда приложение перестаёт отвечать. В Android 11 появился новый API, предоставляющий информацию о недавних причинах завершения процесса, но у большинства наших пользователей установлены более ранние версии ОС, поэтому нам требовалось найти другое решение.

И мы нашли простой способ, который часто используется для отслеживания зависаний главного потока исполнения: запустить watchdog-поток, который периодически будет пытаться выполнить задачу в главном потоке. Если задача не выполняется за определённый промежуток времени, то можно сохранить дамп текущего состояния потоков и отправить его в наш инструмент для анализа отчётов о падениях:

Такую логику реализует, например, библиотека, которой мы воспользовались для реализации репортинга в Gelato. Это позволило нам проводить более глубокий анализ данных и лучше интегрировать этот инструмент в нашу инфраструктуру. Например, теперь мы можем сравнивать зависания главного потока в разных вариантах в ходе A/B-тестирования.

Вот пример отчёта в нашей системе:

Это не реальные данные приложения Bumble, иллюстрация сделана просто для примераЭто не реальные данные приложения Bumble, иллюстрация сделана просто для примера

Полезный совет: собирайте и отправляйте вместе с отчётом лог событий аналитики. Иногда это даёт возможность буквально пошагово воспроизвести проблему.

Если у вас нет своего решения для сбора отчётов о падениях приложения, вы можете настроить репортинг и в сторонние инструменты. Например, можно отправлять ANR-ошибки в App Center или Firebase Crashlytics, так как они предоставляют API для отправки кастомных крашей.

Но помните, что все эти отчёты нельзя считать полной альтернативой ANR-отчётам в Google Play (как мы говорили выше, в Android немного другие правила определения таких ошибок). Но в любом случае это может помочь получить общее представление об основных проблемах. Вполне вероятно, что если генерируется много отчётов о зависании главного потока исполнения в какой-то части вашего приложения, то в ней происходят и ANR-ошибки.

В завершение

Мы обсудили, что представляют собой ANR-ошибки и как их можно отслеживать. Во второй части статьи я расскажу о наших подходах к снижению ANR rate и о том, что из этого получилось.

Ссылки

  1. Android Vitals в Google Play: https://developer.android.com/distribute/best-practices/develop/android-vitals

  2. Отладка ANR: https://developer.android.com/topic/performance/vitals/anr

  3. API для получения причин завершения процесса: https://developer.android.com/reference/kotlin/android/app/ActivityManager#gethistoricalprocessexitreasons

  4. Фреймворк для тестирования веб-страниц: https://www.selenium.dev/

  5. Библиотека для определения зависаний: https://github.com/SalomonBrys/ANR-WatchDog

Подробнее..

SafetyNet Attestation описание и реализация проверки на PHP

11.02.2021 20:09:20 | Автор: admin

В эту тему пришлось детально погрузиться во время работы над обеспечением стандартных механизмов верификации устройств для разных мобильных платформ. Задача сводилась к разработке полноценной реализацию проверки JWS-токенов по протоколу SafetyNet на серверной стороне.

После многочасовых поисков и скрупулёзного изучения официальной документации Google решил поделиться полученным опытом. Потому что, кроме официальной документации, я нашел только отрывочные описания частных примеров реализации на разных ЯП. И ни намека на комплексное объяснение особенностей проверки по SafetyNet на сервере.

Статья будет полезна разработчикам, которые хотят подробнее разобраться с технологией верификации устройств по протоколу SafetyNet Attestation. Для изучения описательной части не обязательно знать какой-либо язык программирования. Я сознательно убрал примеры кода, чтобы сфокусироваться именно на алгоритмах проверки. Сам пример реализации на PHP сформулирован в виде подключаемой через composer библиотеки и будет описан ниже.

Дисклеймер: материал в явном виде содержит перевод официальной документации от Google с разъяснениями и описанием особенностей реализации, с которыми я столкнулся.

О технологии

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

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

Что позволяет проверить технология:

  1. Что именно вы являетесь автором приложения, которое сейчас взаимодействует с сервером.

  2. Что в процессе взаимодействия клиента и сервера нет больше никого, кроме вашего приложения и сервера.

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

В каких случаях механизм не применим или не имеет смысла:

  1. На устройстве пользователя отсутствует интернет, нет возможности связаться с удостоверяющим центром. В таком случае API выдаст ошибку на клиенте, и мы не сможем получить подписанный токен для проверки на сервере.

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

  3. Если требуется детальное понимание статусов модификации системы, на которой работает мобильное приложение. В протокол заложен механизм однозначного определения модификации устройства. Он состоит из двух переменных: ctsProfileMatch и basicIntegrity. Об их назначении чуть ниже.

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

Схематично процесс проверки клиента можно представить в виде схемы:

Рассмотрим поэтапно процесс верификации устройств по протоколу:

  1. Инициация процесса проверки со стороны клиента.Отправка запроса от клиента на Backend на генерацию уникального идентификатора проверки (nonce) сессии. В процессе выполнения запроса на сервере генерируется ключ (nonce) сессии, сохраняется и передаётся на клиент для последующей проверки.

  2. Генерация JSW-токена на стороне удостоверяющего центра.Клиент, получив nonce, отправляет его на удостоверяющий центр вместе со служебной информацией. Затем в качестве ответа клиенту возвращается JWS, содержащий информацию о клиенте, время генерации токена, информацию о приложении (хеши сертификатов, которыми подписывается приложение в процессе публикации в Google Store), информацию о том, чем был подписан ответ (сигнатуру). О JWS, его структуре и прочих подробностях расскажу дальше в статье.

  3. Затем клиент передаёт JWS в неизменном виде на Backend для проверки. А на стороне сервера формируется ответ с информацией о результате прохождения аттестации.После получения статуса проверки JWS на стороне сервера обычно сохраняют факт проверки и, опираясь на него, влияют на функциональность положительным или негативным образом. Например, клиентам, не прошедшим аттестацию, можно отключить возможность писать комментарии, голосовать за рейтинг, совершать иные активности, которые могут повлиять на качество контента приложения или создавать угрозу и неудобство другим пользователям.

Описание процесса верификации на стороне сервера JWS от удостоверяющего центра

Документация Google в рамках тестирования на сервере предлагает организовать online-механизм верификации JWS, при котором с сервера приложения отправляется запрос с JWS на удостоверяющий сервис Google. А в ответе от сервиса Google содержится полный результат проверки JWS.

Но данный метод проверки JWS для промышленного использования не рекомендуются. И даже больше: для каждого приложения существует ограничение в виде 10 000 запросов в сутки (подробнее об ограничениях здесь), после которых вы выгребите квоту и перестанете получать от него вменяемый ответ. Только информацию об ошибке.

Далее расскажу обо всём алгоритме верификации JWS, в том числе о верификации самих сертификатов (проверке цепочки сертификатов).

Подробнее о JWS

JWS представляет собой три текстовых (base64 зашифрованных) выражения, разделенные точками (header.body.signature):

Например:

eyJhbGciOiJSUzI1NiIsICJ4NWMiOiBbInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4xIiwgInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4yIl19.ewogICJub25jZSI6ICJ2ZXJ5c2VjdXJlbm91bmNlIiwKICAidGltZXN0YW1wTXMiOiAxNTM5ODg4NjUzNTAzLAogICJhcGtQYWNrYWdlTmFtZSI6ICJ2ZXJ5Lmdvb2QuYXBwIiwKICAiYXBrRGlnZXN0U2hhMjU2IjogInh5eHl4eXh5eHl4eXh5eHl5eHl4eXg9IiwKICAiY3RzUHJvZmlsZU1hdGNoIjogdHJ1ZSwKICAiYXBrQ2VydGlmaWNhdGVEaWdlc3RTaGEyNTYiOiBbCiAgICAieHl4eXh5eHl4eXh5eHl4eXh5eD09PT09Lz0iCiAgXSwKICAiYmFzaWNJbnRlZ3JpdHkiOiB0cnVlCn0=.c2lnbmF0dXJl

В данном примере после расшифровки base64 получим:

Header :

json_decode(base64_decode(eyJhbGciOiJSUzI1NiIsICJ4NWMiOiBbInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4xIiwgInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4yIl19))={"alg":"RS256","x5c":["verysecurepublicsertchain1","verysecurepublicsertchain2"]}

Body:

json_decode(base64_decode(ewogICJub25jZSI6ICJ2ZXJ5c2VjdXJlbm91bmNlIiwKICAidGltZXN0YW1wTXMiOiAxNTM5ODg4NjUzNTAzLAogICJhcGtQYWNrYWdlTmFtZSI6ICJ2ZXJ5Lmdvb2QuYXBwIiwKICAiYXBrRGlnZXN0U2hhMjU2IjogInh5eHl4eXh5eHl4eXh5eHl5eHl4eXg9IiwKICAiY3RzUHJvZmlsZU1hdGNoIjogdHJ1ZSwKICAiYXBrQ2VydGlmaWNhdGVEaWdlc3RTaGEyNTYiOiBbCiAgICAieHl4eXh5eHl4eXh5eHl4eXh5eD09PT09Lz0iCiAgXSwKICAiYmFzaWNJbnRlZ3JpdHkiOiB0cnVlCn0=))={"nonce":"verysecurenounce","timestampMs":1539888653503,"apkPackageName":"very.good.app","apkDigestSha256":"xyxyxyxyxyxyxyxyyxyxyx=","ctsProfileMatch":true,"apkCertificateDigestSha256":["xyxyxyxyxyxyxyxyxyx=====/="],"basicIntegrity":true}

Signature

json_decode(base64_decode(c2lnbmF0dXJl))= signature

Остановимся на том, что именно содержится во всем JWS.

Header:

  • alg алгоритм, которым зашифрованы Header и Body JWS. Нужен для проверки сигнатуры.

  • x5c публичная часть сертификата (или цепочка сертификатов). Также нужен для проверки сигнатуры.

Body:

  • nonce произвольная строка полученная с сервера и сохранённая на нём же.

  • timestampMs время начала аттестации.

  • apkPackageName название приложения, которое запросило аттестацию.

  • apkDigestSha256 хеш подписи приложения, которое загружено в Google Play.

  • ctsProfileMatch флаг, показывающий прошло ли устройство пользователя верификацию в системе безопасности Google (основной и самый жёсткий критерий, по которому можно понять было ли устройство заручено и прошло ли оно сертификацию в Google).

  • apkCertificateDigestSha256 хеш сертификата (цепочки сертификатов), которыми подписано приложение в Google Play.

  • basicIntegrity более мягкий (по сравнению с ctsProfileMatch) критерий целостности установки.

Signature

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

Проверка сертификатов

Перейдём к непосредственной проверки каждой части полученного JWS. Начнём с сертификатов и алгоритма шифрования:

1. Проверяем, что алгоритм, с помощью которого подписано тело, нами поддерживается:

[$checkMethod, $algorithm] = JWT::$supported_algs[$statement->getHeader()->getAlgorithm()];if ($checkMethod != 'openssl') {   throw new CheckSignatureException('Not supported algorithm function');}

2. Проверяем, что сертификат (цепочка сертификатов), содержащиеся в Header (поле x5c), удовлетворяют нас по содержимому (загружаются в качестве публичных ключей):

private function extractAlgorithm(array $headers): string{   if (empty($headers['alg'])) {       throw new EmptyAlgorithmField('Empty alg field in headers');   }   return $headers['alg'];}private function extractCertificateChain(array $headers): X509{   if (empty($headers['x5c'])) {       throw new MissingCertificates('Missing certificates');   }   $x509 = new X509();   if ($x509->loadX509(array_shift($headers['x5c'])) === false) {       throw new CertificateLoadError('Failed to load certificate');   }   while ($textCertificate = array_shift($headers['x5c'])) {       if ($x509->loadCA($textCertificate) === false) {           throw new CertificateCALoadError('Failed to load certificate');       }   }   if ($x509->loadCA(RootGoogleCertService::rootCertificate()) === false) {       throw new RootCertificateError('Failed to load Root-CA certificate');   }   return $x509;}

3. Валидируем сигнатуру сертификата (цепочки сертификатов):

private function guardCertificateChain(StatementHeader $header): bool{   if (!$header->getCertificateChain()->validateSignature()) {       throw new CertificateChainError('Certificate chain signature is not valid');   }   return true;}

4. Сверяем hostname подписавшего сервера с сервером аттестации Google (ISSUINGHOSTNAME = 'attest.android.com'):

private function guardAttestHostname(StatementHeader $header): bool{   $commonNames = $header->getCertificateChain()->getDNProp('CN');   $issuingHostname = $commonNames[0] ?? null;   if ($issuingHostname !== self::ISSUING_HOSTNAME) {       throw new CertificateHostnameError(           'Certificate isn\'t issued for the hostname ' . self::ISSUING_HOSTNAME       );   }   return true;}

Верификация тела JWS

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

1. Проверка nounce.

Тут все просто. Распаковали JWS, получили в Body nonce и сверили с тем, что у нас сохранено на сервере:

private function guardNonce(Nonce $nonce, StatementBody $statementBody): bool{   $statementNonce = $statementBody->getNonce();   if (!$statementNonce->isEqual($nonce)) {       throw new WrongNonce('Invalid nonce');   }   return true;}

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

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

Есть два параметра, на основе которых можно принимать решение о надежности устройства: ctsProfileMatch и basicIntegrity. ctsProfileMatch более строгий критерий, он определяет сертифицировано ли устройство в Google Play и верифицировано ли устройство в сервисе проверки безопасности Google. basicIntegrity определяет, что устройство не было скомпрометировано.

private function guardDeviceIsNotRooted(StatementBody $statementBody): bool{   $ctsProfileMatch = $statementBody->getCtsProfileMatch();   $basicIntegrity = $statementBody->getBasicIntegrity();   if (empty($ctsProfileMatch) || !$ctsProfileMatch) {       throw new ProfileMatchFieldError('Device is rooted');   }   if (empty($basicIntegrity) || !$basicIntegrity) {       throw new BasicIntegrityFieldError('Device can be rooted');   }   return true;}

3. Проверяем время начала прохождения аттестации.

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

private function guardTimestamp(StatementBody $statementBody): bool{   $timestampDiff = $this->config->getTimeStampDiffInterval();   $timestampMs = $statementBody->getTimestampMs();   if (abs(microtime(true) * 1000 - $timestampMs) > $timestampDiff) {       throw new TimestampFieldError('TimestampMS and the current time is more than ' . $timestampDiff . ' MS');   }   return true;}

4. Проверяем подпись приложения.

Здесь тоже два параметра: apkDigestSha256 и apkCertificateDigestSha256. Но apkDigestSha256 самой Google помечен как нерекомендуемый способ проверки. С марта 2018 года они начали добавлять мета-информацию в приложения из-за чего ваш хеш подписи приложения может не сходиться с тем, который будет приходить в JWS (подробнее здесь).

Поэтому единственным способом проверки остается проверка хеша подписи приложения apkCertificateDigestSha256. Фактически этот параметр нужно сравнить с теми sha1 ключа, которым подписываете apk при загрузке в Google Play.

private function guardApkCertificateDigestSha256(StatementBody $statementBody): bool{   $apkCertificateDigestSha256 = $this->config->getApkCertificateDigestSha256();   $testApkCertificateDigestSha256 = $statementBody->getApkCertificateDigestSha256();   if (empty($testApkCertificateDigestSha256)) {       throw new ApkDigestShaError('Empty apkCertificateDigestSha256 field');   }   $configSha256 = [];   foreach ($apkCertificateDigestSha256 as $sha256) {       $configSha256[] = base64_encode(hex2bin($sha256));   }   foreach ($testApkCertificateDigestSha256 as $digestSha) {       if (in_array($digestSha, $configSha256)) {           return true;       }   }   throw new ApkDigestShaError('apkCertificateDigestSha256 is not valid');}

5. Проверяем имя приложения, запросившего аттестацию.

Сверяем название приложения в JWS с известным названием нашего приложения.

private function guardApkPackageName(StatementBody $statementBody): bool{   $apkPackageName = $this->config->getApkPackageName();   $testApkPackageName = $statementBody->getApkPackageName();   if (empty($testApkPackageName)) {       throw new ApkNameError('Empty apkPackageName field');   }   if (!in_array($testApkPackageName, $apkPackageName)) {       throw new ApkNameError('apkPackageName ' . $testApkPackageName. ' not equal ' . join(", ", $apkPackageName));   }   return true;}

Верификация сигнатуры

Здесь нужно совершить одно действие, которое даст нам понимание того, что Header и Body ответа JWS подписаны сервером авторизации Google. Для этого в исходном виде склеиваем Header c Body (с разделителем в виде ".") и проверяем сигнатуру:

protected function guardSignature(Statement $statement): bool{   $jwsHeaders = $statement->getRawHeaders();   $jwsBody = $statement->getRawBody();   $signData = $jwsHeaders . '.' . $jwsBody;   $stringPublicKey = (string)$statement->getHeader()->getCertificateChain()->getPublicKey();   [$checkMethod, $algorithm] = JWT::$supported_algs[$statement->getHeader()->getAlgorithm()];   if ($checkMethod != 'openssl') {       throw new CheckSignatureException('Not supported algorithm function');   }   if (openssl_verify($signData, $statement->getSignature(), $stringPublicKey, $algorithm) < 1) {       throw new CheckSignatureException('Signature is invalid');   }   return true;}

Вместо заключения. Библиотека на PHP

Уже после решения задачи и отдельно от нашей кодовой базы, я разработал библиотеку на PHP, которая обеспечивает полный цикл верификации JWS.

Её можно скачать из Packagist и использовать в своих прое

Подробнее..

SheetUI сервис для перевода Google Spreadsheets в статику

10.07.2020 12:22:13 | Автор: admin


Недавно в Show HN пришёл стартап SheetUI. Это сервис, который берёт вашу таблицу, парсит её и генерирует статическую страницу с набором карточек. У создателей большие амбиции, но пока что не реализовано много важных функций.

Your browser does not support HTML5 video.


Для чего это нужно?


В комментарии на HN разработчик tjchear рассказал, что у него на глазах сформировался запрос пользователей Google Spreadsheets на быструю автоматическую визуализацию их таблиц. В качестве ответа на него был придуман SheetUI. Нужно только предоставить ссылку на таблицу, разметить в пару кликов поля карточек, и всё сервис предоставит ссылку на сгенерированную страницу. Рендерится она на React с использованием Material-UI.

На сайте sheetui.com перечислены следующие юзкейсы:

  • Каталог товаров у огромной части малого бизнеса вместо каталога используется Excel или, соответственно, Google Sheets. Предполагается что сервис будет автоматически генерировать список товаров с картинками, ценами и кнопками.
  • Список статей помогает шаблонизировать сухой список метаданных. В иллюстрирующей аватарке также изображены шапка и сайдбар, что намекает на будущий функционал, а пока можно просто встроить полученный HTML в нужную часть своего сайта.
  • Команда раздел, представляющий команду (или контактные лица) с фотографией, должностью, описанием и опциональными ссылками, в том числе, на соцсети.
  • Коллекция ничем не отличается от каталога, в демке просто выбран тип карточки с другим позиционированием картинки.
  • Каталог недвижимости и фотогалерея пока нереализованные варианты. В первом, видимо, подразумевается визуализация вложенных списков, а во втором будет слайдер картинок.


Что работает сейчас?


Допустим, у вас есть такая таблица в Google Sheets:



Чтобы сделать из неё список кликабельных карточек серверов, её надо опубликовать через Файл > Опубликовать в Интернете, как на видео выше, и сделать её доступной по ссылке. Эту ссылку общего доступа надо вставить на sheetui.com/edit/0 и выбрать порядковый номер листа.

Нам предложат заполнить заголовок (подтягивается из названия листа по дефолту) и подзаголовок и выбрать раскладку список, сетка или адаптивная (responsive) сетка. В подавляющем большинстве случаев подойдёт последний вариант. Затем выбираем тип карточки:

  • Simple Card текстовая карточка с четырьмя полями (pretitle, title, subtitle и description) и двумя кнопками (отключены по дефолту, появляются при заполнении полей).
  • Media Card image с тремя полями, адресом, alt-текстом и высотой, title, subtitle, description, две обычные кнопки и кнопки с иконками соцсетей на выбор.
  • Media Card 2 аналогична предыдущей, но картинка отображается слева, а не сверху, и нету social buttons.
  • Image Tile просто изображение с опциональным текстом на полупрозрачной плашке снизу.



Simple Card

Настраиваем поля и нажимаем Finish, получаем ссылку на такую страницу:

ссылка

Ещё примеры
Media Card, Image Tile

Минусы


  • Это небезопасно. Опубликованные таблицы и документы регулярно утекают в сеть, поэтому использовать этот сервис для важных данных нельзя ни под каким предлогом. Разработчики пилят интеграцию с Google API, ждём.
  • В карточках фиксированное количество полей, свои добавить нельзя. Можно добавить несколько ячеек в одно поле, как на скриншоте выше, а вот добавить больше двух текстовых кнопок не получится.
  • Сервис генерирует ссылки на своём домене и пока не отдает файлы для селф-хостеда. Но этот функционал обещают допилить.
  • Оформление всегда одно и то же, тем или кастомного css нет. Должно решиться вместе с предыдущим пунктом
  • Сгенерированные ссылки нереально длинные, тоже будет пофикшено.


Итоги


У SheetUI много слабых сторон, но все они должны быть поправлены в будущем. Сервис находится в разработке, и пока что мы видим, по сути, демку. Идея, заложенная в проект, не нова, но действительно удобных и гибких решений её до сих пор нет на рынке, поэтому у SheetUI есть все шансы выстрелить.



На правах рекламы


Многие наши клиенты уже оценили преимущества эпичных серверов!
Это недорогие виртуальные серверы с процессорами AMD EPYC, частота ядра CPU до 3.4 GHz. Максимальная конфигурация позволит оторваться на полную 128 ядер CPU, 512 ГБ RAM, 4000 ГБ NVMe. Поспешите заказать!

Подробнее..

Создание документов на диске Google на базе событий в CRM-системах аmoCRM и Битрикс24

18.02.2021 12:05:10 | Автор: admin

Практический кейс о том, как разработать свой сценарий интеграции.

Появившиеся в 2006 году сервисы Google по работе с текстовыми документами (Google Docs) и таблицами (Google Sheets), дополненные 6 лет спустя возможностями работы с виртуальным диском (Google Drive), завоевали широкую любовь пользователей, лишив компанию Microsoft сложившейся десятилетиями монополии на работу с офисным программным обеспечением.

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

В связи с популярностью последних часто возникает задача сделать новый или обновить существующий Google-файл в привязке к событиям в CRM-системе. Например, при переходе сделки на этап Составление коммерческого предложения необходимо, взяв из CRM-системы наименование клиента и сумму сделки, создать на Google Drive предварительный текст коммерческого предложения, дать к нему доступ юристу, отправив ссылку на редактирование контракта. А когда сделка будет успешно закрыта, может появиться другая задача внести данные по закрытой сделке в Google-таблицу сбора статистики продаж.

В базовом функционале CRM-систем интеграция с сервисами Google представлена лишь в Битрикс24 и предполагает создание пустого документа на базе диска с переходом в режим редактирования при последующем клике на него.

Конечно, в маркетплейс Битрикс24 или дополнениях amoCRM можно найти определенный набор решений для закрытия тех или иных более глубоких сценариев интеграции. Но как быть, если необходимо разработать свой сценарий?

Для того, чтобы выполнить какое-то действие на Google диске своего аккаунта есть два подхода: вызвать соответствующий REST API метод или запустить написанный Google Script. Как показал наш опыт, REST API библиотека Google пока имеет гораздо более бедный набор возможностей по отношению к скриптам, например, с помощью метода create можно создать исключительно пустой документ без наполнения. Поэтому, универсальным является сейчас комбинированный подход, заключающийся в написании исполняемого скрипта, который будет вызываться извне с помощью API с определенным набором параметров.

Разберемся, какие шаги необходимо выполнить, чтобы задействовать этот подход на практике. Нашей задачей будет создать из бизнес-процесса вот такой документ, в котором данные для текста, выделенного желтым, должны прийти из CRM-системы.

Шаг 1. Создание скрипта

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

Сам script добавляется на диске точно также как мы добавляем обычный файл на него.

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

Шаг 2. Привязка скрипта к проекту Google Cloud Platform

После того как скрипт написан рекомендуем прогнать его, нажав кнопку Выполнить в редакторе. Далее вам необходимо создать проект на Google Cloud Platform. Наиболее простым вариантом для этого является нажатие кнопки Enable Google Script API по ссылке:https://developers.google.com/apps-script/api/quickstart/php.

Пройдя все шаги мастера создания API, вы скачиваете файл credentials.json, который будет нужен для работы обработчика. После этого вы берете номер проекта и привязываете его к скрипту в настройках.

Шаг 3. Публикация скрипта

Чтобы скрипт был доступен извне, его надо развернуть через кнопку начать развертывание.

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

Шаг 4. Написание PHP-обработчика

Для работы с API Google существует готовая библиотека, которую вы подключаете к своему проекту командой: composer require google/apiclient:^2.0

В файле с подключенной библиотекой вызываете функцию для подключения токена авторизации:

function getClient(){    $client = new Google_Client();    $client->setApplicationName('Google Apps Script API PHP Quickstart');    // перечисляем области действия токена через пробел    $client->setScopes("https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/drive");    // файл credential.json, полученный от Google$client->setAuthConfig('credentials.json');    $client->setAccessType('offline');    $client->setPrompt('select_account consent');    // Формируемый файл token.json    $tokenPath = 'token.json';    if (file_exists($tokenPath)) {        $accessToken = json_decode(file_get_contents($tokenPath), true);        $client->setAccessToken($accessToken);    }    // Действие если токен просрочен    if ($client->isAccessTokenExpired()) {        if ($client->getRefreshToken()) {            $client->fetchAccessTokenWithRefreshToken($client->getRefreshToken());        } else {            $authUrl = $client->createAuthUrl();            printf("Open the following link in your browser:\n%s\n", $authUrl);            print 'Enter verification code: ';            $authCode = trim(fgets(STDIN));            $accessToken = $client->fetchAccessTokenWithAuthCode($authCode);            print_r($accessToken);            $client->setAccessToken($accessToken);                        if (array_key_exists('error', $accessToken)) {                throw new Exception(join(', ', $accessToken));            }        }        // Сохранение токена в файл        if (!file_exists(dirname($tokenPath))) {            mkdir(dirname($tokenPath), 0700, true);        }        file_put_contents($tokenPath, json_encode($client->getAccessToken()));    }    return $client;}

Файл с этой функцией должен быть изначально исполнен в командной строке ssh клиента php (имя файла).

При первом запуске на экране появится url, который вы открываете в своем браузере и дав необходимые разрешения копируете verification code из url ресурса приложения, который вы указали на шаге 1, вставляя его в командную строку. После этого система создает файл токена авторизации (token.json), сохраняя его в папке PHP-обработчика. Следует отметить,что данную операцию надо выполнить один раз.

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

// вызов ранее описанной функции для получения токена авторизации$client = getClient();$service = new Google_Service_Script($client);// код развернутого скрипта$scriptId = '***********************wKhTTdKL7ChremS5AkvzwlPJARnxqisW7TzDB ';$request = new Google_Service_Script_ExecutionRequest();// имя и параметры функции в рамках скрипта$request->setFunction('FillTemplate');$request->setParameters([$_REQUEST['customer'],$_REQUEST['link'],$_REQUEST['formsv'],$_REQUEST['socnet'],$_REQUEST['whatsi'],   $_REQUEST['ats'],$_REQUEST['pipeline'],$_REQUEST['outscope'],$_REQUEST['editor'],$_REQUEST['viewer']]);try {    // Make the API request.    $response = $service->scripts->run($scriptId, $request);    if ($response->getError()) {        // Обработчик ошибок        $error = $response->getError()['details'][0];        printf("Script error message: %s\n", $error['errorMessage']);        if (array_key_exists('scriptStackTraceElements', $error)) {            print "Script error stacktrace:\n";            foreach($error['scriptStackTraceElements'] as $trace) {                printf("\t%s: %d\n", $trace['function'], $trace['lineNumber']);            }        }    }     } catch (Exception $e) {    // Обработчик исключения    echo 'Caught exception: ', $e->getMessage(), "\n";}

Шаг 5. Вызов PHP-обработчика из CRM

Имея протестированный обработчик, можно запустить его в привязке к событиям CRM.

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

При этом, в Битрикс24 вебхук может быть встроен в бизнес-процесс, получая в качестве своих Get-параметров данные из сделки, переменных и констант бизнес-процесса.

Итогом работы данного вебхука стали добавляемые документы в соответствующую папку Google Drive:

Существенным ограничением запусков обработчиков через вебхуки является предельно допустимая длина URL, в которой будут передаваться Get-параметры. Напомним, что она составляет 4 кб (или 2048 символов).

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

Подробнее..

Из песочницы Как поисковики Google и Yandex мешают открыть иностранный банковский счет

27.08.2020 16:05:58 | Автор: admin
В Латвии, где ещё совсем недавно массово открывали счета россияне и граждане других стран СНГ, выпустили Справочник по борьбе банков с отмыванием. И хотя ничего концептуально нового латвийские регуляторы не предложили, они подчеркнули важный для современного бизнеса момент: поводом для пристального внимания и даже отказа в открытии счета может послужить поисковая выдача в популярных поисковиках.



Вы спросите: зачем нам Латвия, если там давно не интересно открывать счета айтишнику? Дело в том, что информацию из поисковиков и социальных сетей активно используют и другие банки по всему миру. И IT-шники, которые живут и созидают в виртуальных мирах зачастую оставляют в сети много следов.

Латвийский пример борьбы с отмыванием нелегальных средств


Латвийская газета Diena провела своё расследование нового справочника, который обещали сделать помощником бизнеса и банков. Все стороны рассчитывали на разъяснения, на пошаговые инструкции и чёткое понимание, как стоит действовать, чтобы открыть счет. Именно это обещала обеспечить нынешняя глава Комиссии рынков финансов и капитала (Finanu un kapitla tirgus komisija FKTK) менее года назад.


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

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

Особенно острые приступы паранойи случаются у банков, если у клиента есть партнёры в России, Казахстане, Белоруссии, Украине и прочих банку также проще отказать, нежели разбираться в деловых связях и реальных бизнесах. Даже подробные пояснения по каждой сделке банку не нужны он закрывает счет и просит вывести деньги в течение 1-6 месяцев.
Интересным дополнением картины является необходимость проверять каждого клиента при помощи поисковых систем. Клиентов из стран СНГ просят проверят как через Google, так и через Яндекс и делать запросы на русском языке.

В частности, предлагается брать ФИО клиента или название компании и вбивать в поиск вместе с такими ключевыми словами, как: отмывание, терроризм, штраф, санкции, запрет, мошенничество, арест, наркотики, коррупция и другими.

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

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

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

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

И снова вы спросите: а если я открываю счет в США или в Португалии как это меня касается?
Прямым образом.

Google, соцсети и прочие источники информации для анализа клиентов банка


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

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



Изначально клиентов проверяли при помощи специальных платных баз. Однако в современном мире по множеству лиц можно найти гораздо больше информации в открытом или полуоткрытом виде в Интернете. Социальные сети, Google и изредка, как в Латвии, другие поисковые системы используются сотрудниками отдела compliance в полную силу.

Если отделы HR при приёме сотрудников пользуются этим методом, то чем отличается банк, которому нужно выбрать идеального клиента?

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

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

А теперь представьте, что такой отдел принимает решение по вашему IT-проекту, который решил идти не по проторённой кем-то дорожке, а создавать что-то новое? Или запустить новую версию бизнеса, который и так считается рискованным, например, криптовалюты или онлайн-гемблинг.

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

И вот подумайте, как повернётся ситуация, если проверяющие начнут изучать поисковую выдачу и наткнуться на какую-то негативную новость о вас, вашем бизнес или партнёре? Шанс на открытие счета резко упадёт, даже если опубликованная информация является слухом, заказным чёрным пиаром и прочим непредсказуемым фактором. Такое специально могут организовать конкуренты, чтобы нанести удар по вам.

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

Как всё-таки открыть счет за границей для бизнеса


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

Также адекватные банки учитывают пропорцию проблем и выгод, которые принесёт клиент. С одной стороны, клиент из России это всегда определённый вызов. С другой если бизнес клиента прозрачный, а прибыль по бизнес-плану исчисляется миллионами, то почему бы не начать сотрудничество?

К тому же есть и совершенно объективные факторы, которые нужно учитывать:

  • Если вы планируете работать на Европейском рынке, то открывать счет в США может быть непрактично лучше сразу искать счет в ЕС или ближе к месту ведения бизнеса;
  • Если ваш бизнес связан с рискованной деятельностью, то стоит получить лицензию уважаемой страны и нанять профессионалов в этой области они вызовут доверие у банка гораздо легче, чем только один вы;
  • Качество подготовки документов также играет большую роль: любые ошибки и опечатки способны привести к отказу на, казалось бы, ровном месте. К тому же у каждого банка есть свои нюансы. Самый анекдотический пример, но реально помешавший многим клиентам требование заполнят заявку только синей пастой. Те, кто писал чёрной ручкой сразу лишались возможности открыть счет.




Поэтому вывода два:

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

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

  • Определиться с целями счета, уточнить, в каких условиях он будет использоваться.
  • Подобрать банк, который работает с вашим типом клиентов здесь стоит обратиться к профессионалам, которые помогут выбрать счет быстрее и без лишней нервотрёпки.
  • Подготовить документы.
  • Также банк может запросить дополнительные документы.
  • После этого потребуется ждать решение банка. В идеальном случае это займёт неделю-другую (при удалённо регистрации; при личной решение могут озвучить быстрее) в худшем же придётся ждать месяцы, постоянно отвечая на дополнительные вопросы банка.

Базовый набор документов для компании:


  • Корпоративные документы (устав, меморандум и т.п.);
  • Личные документы и доказательство адреса проживания владельца, директора;
  • Описание бизнеса, бизнес-план;
  • Банковские формы;
  • Если бизнес существует какое-то время финансовая отчётность.

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

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

Сегодня у банков есть альтернатива: платёжные системы. Они предлагают аналогичный сервис, но зачастую быстрее и дешевле. Это отличный вариант для старта и подстраховки бизнеса. Но и здесь нужно понимать: быстро открыли быстро закрыли по минимальному поводу.

Так что выбирать нужно не только банк, но и платёжную систему и быть аккуратными и там, и там.

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

Простыми же словами это будет так: мир вокруг может меняться, вместе с правилами и условиями. Бизнес может получать удар за ударом и счет могут закрывать за счетом. Но если вы постепенно становитесь сильнее, создаёте страховочные сети и переживаете даже глобальные пандемии, то вы антихрупки. И иностранные счета помогут вам в этом процессе.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru