Как работать с API

Порой реклама курсов застаёт врасплох.

Обычно я всё узнаю из открытых источников и документаций, но при виде объявления "Купите курс "Как продать курс" даже мне сложно устоять и не подписаться на бесплатный вебинар чудодейственной методики. И вот буквально через минуту я уже мысленно рисую логотип своего SkillPropil... Сегодня про одну из популярных тем курсовых – API.

Усатый нянь

Тонкости настройки аналитики в Telegram канале

Что такое API

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

- Слышь, сюда иди. Дай айфон позвонить.

Разберём подробно, что в данном случае делает ваш более опытный коллега:

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

Конечно есть примеры и попроще. Если вы часто работаете с Google Tag Manager, то наверняка не раз общались c DOM через его API как-нибудь вот так:

document.querySelector(
  '#gatsby-focus-wrapper > div > div > div.fixed.z-10.bottom-0.left-0.w-screen.h-screen.bg-white.overflow-y-scroll.flex.flex-col-reverse > nav > div:nth-child(2) > a:nth-child(3)'
)

Однако сегодня мы всё-таки остановимся на чем-то не таком избитом, и разберём способ авторизации Oauth2, используемый Яндекс и Google, но на примере более популярного сервиса - Admitad, а бонусом сделаем Telegram бота для регулярной автоматической отчетности.

Принцип авторизации OAuth2

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

Таблица полигамных отношений

ПартнерОбязанности
СервисAdmitad, предоставляющий методы API
ПользовательАккаунт в Сервисе, на котором находятся необходимые данные
ПриложениеКод Разработчика, использующий API Сервиса для доступа к данным Аккаунта

Сам коитус обычно происходит следующим образом:

  1. Разработчик 👶 регистрирует своё Приложение в Сервисе и получает для него всякие там аналоги СНИЛС и ИНН: как минимум идентификатор Приложения client_id, бывают еще адрес доставки redirect_uri, пароли и секретики, куда же без них 😊
  2. Почуяв слабину, Приложение запрашивает у Сервиса доступ к данным Пользователя, показывая ксиву с прошлого шага, а также обозначив, куда именно будет проникать и что себе там будет позволять: scope и grant_type
  3. Сервис предупреждает, что оплата почасовая, возвращает Приложению временный пропуск access_token со сроком действия expires_in, а так же карточку постоянного покупателя refresh_token
  4. Когда природа снова позовёт, Приложение может уже самостоятельно обновить свой временный пропуск access_token с помощью выданного refresh_token, потому что в конце концов все же свои 😜

Общий принцип можно еще почитать по ссылке 18+
https://tools.ietf.org/html/draft-ietf-oauth-v2-16

Подключение к Admitad API

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

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

Пример на каком-то там питоне 🐍
# копипаст из доки для Клиентской авторизации
from base64 import b64encode
# client_id и client_secret со страницы https://developers.admitad.com/
client_id='413a534538df8f2ckjwia8d7725c23b'
client_secret='2d3eab98386dd8wkwe84j7e2a71b6b'
data = client_id + ':' + client_secret
data_b64_encoded = b64encode(data)

# запрос на получение токенов
import requests
url = 'https://api.admitad.com/token/?grant_type=client_credentials&client_id=' + client_id + '&scope=statistics'
headers = {'Authorization': 'Basic ' + data_b64_encoded}

authInfo = requests.post(url, headers=headers)

print(authInfo.text)

Пример на Старшей Речи 😍

// все слишком очевидно
const https = require('https')
const [client_id, client_secret] = [
  '413a534538df8f2ckjwia8d7725c23b',
  '2d3eab98386dd8wkwe84j7e2a71b6b',
]

const options = {
  method: 'POST',
  hostname: 'api.admitad.com',
  path:
    '/token/?grant_type=client_credentials&client_id=' +
    client_id +
    '&scope=statistics',
  headers: {
    Authorization:
      'Basic ' +
      new Buffer.from(client_id + ':' + client_secret).toString('base64'),
  },
}

const request = https.request(options, response => {
  response.setEncoding('utf8')
  response.on('data', function(chunk) {
    console.log(chunk)
  })
})

request.write('')
request.end()

Если всё прокатило, то адмитад вернёт строку с json, в которой нас в первую очередь интересует поле access_token, а чтобы узаконить отношения, понадобятся еще refresh_token и expires_in.

Скачивание данных из Admitad

Наконец, можно получить сами данные. Пока сильно наглеть не будем, десяток строчек.

Снова пример на никому не нужном 🐍
import json
# access_token из данных с прошлого шага
accessToken = json.loads(authInfo.text)['access_token']
# даты и лимит
url = 'https://api.admitad.com/statistics/actions/?date_start=01.12.2019&date_end=05.12.2019&limit=10'
headers = {'Authorization': 'Bearer ' + accessToken}
# согласно доке get вместо post
data = requests.get(url, headers=headers)

print(data.text)

Пример на любимом языке Деда Мороза, которого вы конечно не хотите расстроить

// да, node.js
const https = require('https')
const accessToken = '07d0787r1985c49958eb'

const options = {
  method: 'GET',
  hostname: 'api.admitad.com',
  path:
    '/statistics/actions/?date_start=01.12.2019&date_end=05.12.2019&limit=10',
  headers: {
    Authorization: 'Bearer ' + accessToken,
  },
}

const req = https.request(options, res => {
  res.setEncoding('utf8')
  res.on('data', function(chunk) {
    console.log(chunk)
  })
  req.on('error', error => {
    console.error(error)
  })
})

req.write('')
req.end()

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

Работа с Admitad в Apps Script

Переходим к неофициальной части, где разберемся, как это всё получать регулярно в лучшем виде и месте. Автоматизировать буду на Apps Script, потому что это бесплатно и похоже на Javascript 😍, так что никаких 🐍 больше.

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

Итак, дайте жизнь новому проекту на https://script.google.com, далее пойдут листинги с комментами.

Новый проект на script.google.com

И создал я в первый день файл с конфигом config.gs и занёс в него выданные Адмитадом credentials

Сonfig.gs
const config = {
  clientId: '413a534538df8f2ckjwia8d7725c23b',
  clientSecret: '2d3eab98386dd8wkwe84j7e2a71b6b',
}

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

Admitad.gs
function Admitad(config) {
  function authorize(callback) {
    // собираем запрос
    var url =
      'https://api.admitad.com/token/?grant_type=client_credentials&client_id=' +
      config.clientId +
      '&scope=statistics'
    var options = {
      method: 'post',
      headers: {
        Authorization:
          'Basic ' +
          Utilities.base64Encode(config.clientId + ':' + config.clientSecret),
      },
    }
    // для отладки,[] позволяет вывести несколько значений, посмотреть по Ctrl(Cmd) + Enter
    Logger.log(['=== AUTH REQUEST', url, options])
    // отправляем запрос
    var response = UrlFetchApp.fetch(url, options).getContentText()
    var json = JSON.parse(response)
    Logger.log(['=== AUTH RESPONSE', json])
    // проверяем ответ
    if (json) {
      // сохраняем все токены
      config.expirationTime = Date.now() + parseInt(json.expires_in) * 1000
      config.accessToken = json.access_token
      config.refreshToken = json.refresh_token
    } else {
      Logger.log(['Ошибка авторизации', response])
    }
    // callback нужен, чтобы последовательно выполнить еще действие
    return callback()
  }
}

accessToken, refreshToken и expirationDate периодически будут обновляться, поэтому их временные значения надо куда-то сохранять. Я нагуглил в Apps Script по этому поводу глобальный объект Properties service, соответственно, в конфиг добавил геттеры и сеттеры, и вроде это удобно, хоть и не выглядит так 🤣

Сonfig.gs
var config = {
  // ...

  get accessToken() {
    if(!this._accessToken) {
      var scriptProperties = PropertiesService.getScriptProperties()
      this._accessToken = scriptProperties.getProperty('admitad.accessToken')
    }
    return this._accessToken
  },
  set accessToken(value) {
    var scriptProperties = PropertiesService.getScriptProperties()
    scriptProperties.setProperty('admitad.accessToken', value)
    this._accessToken = value
  },

  get refreshToken() {
    if(!this._refreshToken) {
      var scriptProperties = PropertiesService.getScriptProperties()
      this._refreshToken = scriptProperties.getProperty('admitad.refreshToken')
    }
    return this._refreshToken
  },
  set refreshToken(value) {
    var scriptProperties = PropertiesService.getScriptProperties()
    scriptProperties.setProperty('admitad.refreshToken', value)
    this._refreshToken = value
  },

  get expirationTime() {
    if (!this._expirationTime) {
      var scriptProperties = PropertiesService.getScriptProperties()
      this._expirationTime = scriptProperties.getProperty('admitad.expirationTime')
    }
    return this._expirationTime
  },
  set expirationTime(value) {
    var scriptProperties = PropertiesService.getScriptProperties()
    scriptProperties.setProperty('admitad.expirationTime', value)
    this._expirationTime = value
  },
}

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

Admitad.gs
function Admitad(config) {
  // ...

  function refreshToken(callback) {
    if (config.refreshToken) {
      var url = 'https://api.admitad.com/token/'
      var options = {
        method: 'post',
        payload:
          'grant_type=refresh_token' +
          '&client_id=' +
          config.clientId +
          '&refresh_token=' +
          config.refreshToken +
          '&client_secret=' +
          config.clientSecret,
      }
      Logger.log(['=== REFRESH REQUEST', url, options])
      var response = UrlFetchApp.fetch(url, options).getContentText()
      var json = JSON.parse(response)
      Logger.log(['=== REFRESH RESPONSE', json])
      if (json) {
        config.expirationTime = Date.now() + parseInt(json.expires_in) * 1000
        config.accessToken = json.access_token
        config.refreshToken = json.refresh_token
      } else {
        Logger.log(['Ошибка обновления токена', response])
      }
      return callback()
    } else {
      // если refreshToken нет, вызываем обычную авторизацию
      return authorize(callback)
    }
  }
}

Последний метод Адмитада получает данные по транзакциям. Здесь немножко tricky, потому что количество строк за раз ограничено 500. Чтобы получить остальные, в запросе используется параметр offset со стартовой точкой.

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

Admitad.gs
function Admitad(config) {
  // ...

  // начало и конец периода, накопленные данные
  this.actions = function(first, last, data) {
    // если данных еще нет
    data = data || []

    // вспомогательная функция для проверки токена
    var actionsHelper = function() {
      var url =
        'https://api.admitad.com/statistics/actions/?date_start=' +
        first +
        '&date_end=' +
        last +
        '&limit=500&offset=' +
        data.length
      var options = {
        method: 'get',
        headers: {
          Authorization: 'Bearer ' + config.accessToken,
        },
      }
      var response = UrlFetchApp.fetch(url, options).getContentText()
      var json = JSON.parse(response)

      if (json) {
        var results = json.results

        // аккумулируем полученные данные
        data = data.concat(results)
        Logger.log(['=== TOTAL', json._meta.count, ' CURRENT ', data.length])

        // json._meta.count содержит общее количество строк по запросу
        // проверяем остатки и рекурсивно вызываем при необходимости, отдавая накопленные данные
        return json._meta.count > data.length
          ? this.actions(first, last, data)
          : data
      } else {
        Logger.log(['Ошибка получения actions', response])
      }
      return data
      // привязываем контекст this, чтобы можно было выполнить this.actions()
    }.bind(this)

    // обновляем протухший accessToken
    return (config.expirationTime && config.expirationTime <= Date.now()) ||
      !config.accessToken
      ? refreshToken(actionsHelper)
      : actionsHelper()
  }
}

Агрегация данных

Пришло время возрадоваться всем любителям 🐍, наконец будут знакомые слова, потому что функции для работы с данными я засунул в файл Dataframe.gs, на этом всё.

Обязательно нужен счетчик транзакций, который возьмёт уникальные значения order_id. Замечу, что, например, у АлиЭкспресса (на момент написания поста) каждая позиция - отдельный order_id, то есть купил там пользователь оптом десяток одинаковых безделушек – вы увидите 10 транзакций. Для таких случаев имеет смысл считать конкатенации какого-нибудь идентификатора пользователя из subid и времени покупки action_date, но сейчас попроще.

Dataframe.gs
function Dataframe(data) {
  this.transactions = function(status) {
    // по умолчанию считаем все транзакции, а можно передать и approved|pending
    status = status || '.*'

    // вспомогательная функция для подсчета уников
    function countDistinct(arr) {
      var counts = {}
      for (var i = 0; i < arr.length; i++) {
        counts[arr[i]] = 1 + (counts[arr[i]] || 0)
      }
      return Object.keys(counts).length
    }

    var orders = data
      // фильтруем только нужные статусы
      .filter(function(order) {
        return order.status.match(status)
      })
      .map(function(t) {
        // достаём номера заказов
        return t.order_id
      })

    return countDistinct(orders)
  }
}

Ну и куда же без 💰, их тоже посчитаем.

Dataframe.gs
function Dataframe(data) {
  // ...

  this.revenue = function(status) {
    status = status || '.*'

    return data
      .filter(function(order) {
        return order.status.match(status)
      })
      .reduce(function(s, c) {
        var t = c.payment * config[c.currency]
        return s + t
      }, 0)
  }
}

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

Config.gs
var config = {
  clientId: '413a534538df8f2ckjwia8d7725c23b',
  clientSecret: '2d3eab98386dd8wkwe84j7e2a71b6b',
  'USD': 66,  'EUR': 75,  'RUB': 1,
  // ...

Отправка отчетов в Telegram

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

Config.gs
var config = {
  clientId: '413a534538df8f2ckjwia8d7725c23b',
  clientSecret: '2d3eab98386dd8wkwe84j7e2a71b6b',
  'USD': 66,
  'EUR': 75,
  'RUB': 1,
  botToken: '862713267:AAHavQqWrRTsktlDMeU5SGFgorkwkcwWRtYc',  groupId: '289798293',
  // ...

Небольшая функция для самой отправки.

Telegram.gs
function telegram(message) {
  UrlFetchApp.fetch(
    'https://api.telegram.org/bot' + config.botToken + '/sendMessage',
    {
      method: 'post',
      payload: {
        chat_id: config.groupId,
        parse_mode: 'Markdown',
        text: message,
      },
    }
  )
}

Автоматизация отчетности

Пришло время всё это склеить и поставить на таймер.

Oleg.gs
function Oleg() {
  // помощницы для красивостей
  function r(num) {
    return Math.floor(num)
  }
  function p(num) {
    return ' (' + Math.floor(num * 100) + '%)'
  }

  var data = []
  var admitad = new Admitad(config)

  // достаем последние 7 дней
  var yesterday = new Date(new Date().setDate(new Date().getDate() - 1))
  var _8days_ago = new Date(new Date().setDate(new Date().getDate() - 8))
  // Utilities - приблуда Apps Script с полезными штуками
  data = admitad.actions(
    Utilities.formatDate(_8days_ago, 'GMT+3', 'dd.MM.yyyy'),
    Utilities.formatDate(yesterday, 'GMT+3', 'dd.MM.yyyy')
  )

  var df1 = new Dataframe(data)
  var tr1 = df1.transactions()
  var re1 = df1.revenue()

  // предыдущие 7 дней для сравнения
  var _9days_ago = new Date(new Date().setDate(new Date().getDate() - 9))
  var _16days_ago = new Date(new Date().setDate(new Date().getDate() - 16))
  data = admitad.actions(
    Utilities.formatDate(_16days_ago, 'GMT+3', 'dd.MM.yyyy'),
    Utilities.formatDate(_9days_ago, 'GMT+3', 'dd.MM.yyyy')
  )

  var df2 = new Dataframe(data)
  var tr2 = df2.transactions()
  var re2 = df2.revenue()

  // собираем соообщение
  var message =
    '\n*Last 7, total*' +
    '\n' +
    '\n*Orders*: ' +
    tr1 +
    p(tr1 / tr2 - 1) +
    '\n*Revenue*: ' +
    r(re1) +
    p(re1 / re2 - 1)

  // отправляем сообщение
  telegram(message)
}

Чтоб потестить, сделайте Run функции Oleg(). На вкладке Edit есть ссылка на триггеры проекта, идём по ней и жмякаем Add Trigger справа внизу.
Вот пример настроек

Триггер для запуска отчета

Ограничения

  1. Apps Script
  2. Admitad

На выполнение скрипта GAS выделяет 6 минут, так что отчет за год так качать не стоит, а за вчера, текущий месяц и MoM вместе вполне себе можно. Иногда Admitad не отвечает 😤, но бывает это нечасто.

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