Медиация рекламы своими руками

103 minute read

Введение

Продолжаю писать про монетизацию мобильных приложений в России в современных условиях. В прошлой статье я рассказывал как реализовать подписки в мобильных приложениях через сервис boosty. Надеюсь, у меня получится большая серия статей по монетизации. В этой статье разберемся что такое медиация в мобильной рекламе и как она работает. Естественно, статья без реализации в коде - буквы на ветер. Тут я опишу рабочую систему, которой пользуюсь сам в своих приложениях и потихоньку отлаживаю. Весь код доступен на gitflic: тут бековая часть, а тут часть мобилки(пока только android). Если хотите погонять в своем приложении, то напишите мне в телеграме @akovardin.

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

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

Терминология

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

  • CPM - cost per mile - цена за тысячу показов рекламного объявления.
  • eCPM - effective CPM - эффективная цена за тысячу показов. Если CPM это фактическое значение, то eCPM это расчетное или предсказанное значение, которое может использоваться в алгоритмах для принятия решения о покупке показа.
  • CPA - cost per action - цена за действие.
  • CPI - cost per install - цена за установку.
  • placement/плейсмент - место в мобильном приложении, где будет сделан показ рекламного объявления.
  • unit/юнит - ид рекламного места в рекламной сетке. Например, в MyTacker это называется слот.
  • bid/бид - ставка, цена за которую сетка готова купить показ. Часто в качестве bid используется eCPM.
  • SDK - software development kit - в контексте этой статьи, SDK это реализация работы с рекламой на стороне мобилки.
  • паблишер/издатель - тот, кто публикует приложение. Как правило, это сам разработчик приложения, но не всегда.
  • РСЯ - рекламная сеть Яндекса.
  • RTB - real time bidding - торги в реальном времени. Как правило, применяется для обозначения серверной системы, которая позволяет проводить эти торги

Очень кратко про платформы

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

  • admob - в первую очередь, это гугл и самый большой игрок на рынке рекламных сетей. У них есть своя медиационная платформа.
  • digitalturbine - это очень большой паблишер, они купили AdColony пару нел назад и теперь у них есть своя система медиации.
  • appodeal - крутая платформа медиации, которая позволяет подключить много рекламных сеток.
  • ironSource - рекламная сетка с игровым трафиком. У них есть свое решение для медиации LevelPlay.
  • applovin - еще одна большая рекламная сетка со своей платформой медиации. Эти ребята делают очень крутую медиацию и можно подсмотреть как они делают адаптеры.

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

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

  • admost - основная специализация - медиация рекламы.
  • tradplusad - азиатская платформа медиации. Перспективное направление.
  • toponad - еще одна перспективная платформа для медиации.

Что я есть нашего, родного? Если вы хотите получать выплаты в рублях, то я знаю только одну медиационную платформу - это Яндекс. В РСЯ есть возможность подключать много рекламных сеток. А их киллер фича - это простая монетизация, в которой они все делают за вас и выплачивают доход одним платежом. Всего за 10%.

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

Теория

Начнем с формального определения что такое медиация. В вики написано так:

Медиа́ция (лат. mediare — посредничать) — особый вид переговоров, при котором нейтральный посредник помогает сторонам в конфликте найти взаимовыгодное решение.

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

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

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

Более классическое разделение типов медиации - это разделение по принципу проведения аукциона. Есть два подхода - водопад(waterfall) и in-app bidding. Хз как по человечески перевести in-app bidding(дословно - “торги в приложении”), но суть там в bid, то есть в ставке.

Если вы большая рекламная сетка, то можно подключить интеграцию через RTB, например Яндекс или mytarget. Но для этого вы должны быть большим и известным игроком. Когда-нибудь вернемся к этой теме.

Если при запросе за рекламой мы сразу можем получить bid в рекламном SDK - то такой подход называют client bidding.

Очень кратко, но красиво, описываются стратегии медиации вот в этом видео от Яндекса:

Неплохая статья, а которой рассказано про стратегии медиации

Waterfall(Водопад)

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

В примере выше мы показываем баннер из второй рекламной сетки.

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

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

Решить проблему водопада решили с помощью bidding. В приближенном переводе это можно назвать аукционом

In-app bidding

In-app bidding как только не называют: in-app header bidding, app bidding, mobile bidding. Но везде суть одна - предлагаем пользователя сеткам и запрашиваем ставки от сеток. При этом, запросы мы выполняем одновременно ко всем сеткам.

Логика аукциона реализуется на стороне бекенда. Чтобы опросить сетки и узнать ставки для этого показа - нужно на клиенте собрать специальные токены. Например, получения токена для MyTarget выглядит так

1val token = MyTargetManager.getBidderToken(context)

Или посложнее в Яндекс

1val loadListener = object : BidderTokenLoadListener {
2    override fun onBidderTokenLoaded(token: String) = callback.onTokenLoaded(token)
3    override fun onBidderTokenFailedToLoad(msg: String) = callback.onTokenFailedToLoad()
4}
5
6BidderTokenLoader.loadBidderToken(context, tokenRequest, loadListener)

Кстати, тут подробно описано как тянуть Яндекс SDK в свою медиацию при использовании in-app bidding.

К сожалению, подключить сетки по in-app bidding не так просто. Нужно партнериться с рекламными сетками и договариваться напрямую с менеджерами Яндекс. Такая же ситуация с MyTarget.

Большинство сеток, которые работают через in-app bidding или waterfall интегрируются через сервер, как показано на схеме ниже:

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

Довольно неплохо описана работа медиации в статье Appodeal.

Client bidding

Это самый приятный формат. Я его подсмотрел в рекламной сетке bigo и влюбился.

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

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

Подключение разных рекламных сеток

Я опишу и расскажу как подключить разные рекламные сетки. К сожалению, нет какого-то единого стандарта, все SDK очень разношерстные. Тем не менее, можно выделить общие принципы работы с рекламой в мобильном приложении и жизненный цикл любого рекламного материала можно разделить на несколько этапов:

  • Инициализация SDK при старте приложения. В некоторых сетках нужно указывать ключи для работы SDK и дополнительные параметры. Некоторые работают сходу и их достаточно добавить в зависимости.
  • Загрузка рекламы. Загрузка специально отделена от показа, потому что это довольно долгий процесс. Именно на этом шаге мы будем выполнять запросы для получения ставок и получать ответы со ставками от сторонних сеток. В некоторых случаях, это будет реализовано напрямую через SDK, а где-то это будет через наш бековый сервис.
  • Показ рекламного объявления. Перед показом рекламы должен быть проведен аукцион и выбрана сетка, которая предложила самую большую ставку. После показа нам очень важно залогировать факт показа на нашем бекенде с указанием его стоимости. Как правило, это будет тот самый bid, который мы получили на этапе загрузки рекламы, но некоторые сетки могут отдавать стоимость показа в самом событии.
  • Получение актуальной информации по показам и заработку, чтобы рассчитать актуальные eCPM, которые мы сможем использовать на первом шаге.

Схема работы SDK медиации:

Дальше я буду рассказывать про реализацию адаптеров для конкретных сеток. С каждой новой сеткой будет добавляться новая логика для правильной работы client bidding.

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

 1class Mediation {
 2    private val tag = "Mediation"
 3    private val service = NetworksService()
 4
 5    var adapters = mapOf<String, MediationAdapter>()
 6
 7    private fun init(context: Context, app: String, adapters: Map<String, MediationAdapter>) {    
 8      // ...    
 9    }
10
11    companion object {
12        lateinit var instance: Mediation
13
14        fun init(context: Context, app: String, adapters: Map<String, MediationAdapter>) {
15            instance = Mediation()
16            instance.init(context = context, app = app, adapters = adapters)
17        }
18    }
19}

В статье я буду приводить только базовый код и какие-то особенно важные части. Подробно реализацию можно посмотреть в репозитории getapp-sdk-android.

В коде выше метод init принимает список адаптеров. Некоторые SDK должны быть инициализированы до того как их использовать(например, bigo). Для того мы должны получить список подключенных к приложению рекламных сеток и вызвать метод init() у каждого адаптера.

 1private fun init(context: Context, app: String, adapters: Map<String, MediationAdapter>) {
 2    this.adapters = adapters
 3
 4    scope.launch {
 5        // загружаем список подключенных медиаций и их ключи для инициализации
 6        service.fetch(app, object : NetworkHandler {
 7            override fun onFailure(e: Throwable) {
 8                Log.e(tag, e.message.toString())
 9            }
10
11            override fun onSuccess(resp: NetworkResponse) {
12                // инициализируем только те sdk, которые добавлены в приложение 
13                // и указаны на стороне сервера
14                launch(Dispatchers.Main) {
15                    // большинство адаптеров нужно инитить на главном треде
16                    // возможно, нужно будет перенести внутрь адаптеров
17                    for (network in resp.networks) {
18                        if (this@Mediation.adapters.containsKey(network.name)) {
19                            val adapter = this@Mediation.adapters[network.name]
20                            adapter?.init(context, network.key)
21                        }
22                    }
23                }
24            }
25        })
26    }
27}

NetworksService ходит за списком сеток, после чего мы в цикле вызываем метод init у адаптеров сетки. Все адаптеры реализуют общий интерфейс MediationAdapter.

1interface MediationAdapter {
2    fun init(context: Context, key: String)
3    fun token(context: Context): String
4    fun createInterstitial(placement: Int, unit: String, callbacks: InterstitialCallbacks): InterstitialAdapter
5    // потом тут появятся методы для остальных форматов рекламы
6}
  • init - нужен для инициализации сетки.
  • token - когда ни будь его можно будет использовать для интеграции через in-app bidding.
  • createInterstitial - метод, который возвращает объект интерстишела из рекламной сетки и который соответствует интерфейсу InterstitialAdapter.

Общая схема работы показана ниже. В классе Mediation через композицию используются адаптеры сетки. А в классе Interstitial точно так же – через композицию – используются адаптеры уже для конкретного формата рекламы. Сейчас только для формата межстраничного объявления(интерстишела) Interstitial.

Адаптеры для формата, например интерстишела, должны реализовывать интерфейс InterstitialAdapter

1interface InterstitialAdapter {
2    fun load(context: Context)
3    fun show(activity: Activity)
4    fun bid(): Double
5    fun win(price: Double, bidder: String)
6    fun loss(price: Double, bidder: String, reason: Int)
7    fun network(): String
8}
  • load - загрузка рекламы и получение бида.
  • show - показ рекламного объявления.
  • bid - метод возвращает значение ставки, этот метод используется для аукциона в классе Interstitial.
  • loss и win - методы вызываются во время проигрыша и выигрыша аукционна соответственно.
  • network - просто возвращает название сетки.

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

В load нам нужно получить и сохранить ставку по каждому юниту из рекламных сеток. Эти ставки мы получаем через коллбек fun onLoad(ad: InterstitialAdapter). Он срабатывает только в том случае, если реклама из сетки загрузилась без ошибок.

 1fun load(context: Context) {
 2    bets.clear()
 3
 4    scope.launch {
 5        placements.get(id, object : PlacementsHandler {
 6            override fun onFailure(e: Throwable) {
 7                Log.e(tag, e.message.toString())
 8            }
 9
10            override fun onSuccess(resp: PlacementResponse) {
11                // бежим по всем адаптерам, создаем загружаем стишелы и сохраняем ставки
12                for (u in resp.units) {
13                    // unit - это ид рекламной позиции в конкретной рекламной сетке
14                    // placement - ид рекламной позиции в сервисе getapp
15                    val unit = u.unit
16                    val placement = u.placement
17                    val network = u.network
18
19                    val adapter = Mediation.instance.adapters[network] ?: continue
20
21                    adapter.createInterstitial(placement = placement, unit = unit, callbacks = object : InterstitialCallbacks {
22                        override fun onLoad(ad: InterstitialAdapter) {
23                            // в мапе bets сохраняем все загруженные стишелы
24                            bets[unit] = ad
25
26                            callbacks.onLoad(ad)
27                        }
28
29                        override fun onImpression(ad: InterstitialAdapter, data: String) {
30                            callbacks.onImpression(ad, data)
31                        }
32
33                        override fun onFailure(message: String) {
34                            callbacks.onFailure(message)
35                        }
36
37                        // тут будут остальные коллбеки, которые передаются в реализацию 
38
39                    }).load(context)
40                }
41            }
42
43        })
44    }
45}

А теперь самый простой и самый важный кусок кода в классе Interstitial. Реализуем аукцион и выбираем какая сетка победила:

 1fun show(activity: Activity) {
 2    if (bets.isEmpty()) {
 3        return
 4    }
 5
 6    val ad = bets.toSortedMap().maxBy { it.value.bid() }
 7
 8    bets.map {
 9        if (it.key != ad.key) {
10            // сообщаем всем остальным почему проиграли
11            it.value.loss(ad.value.bid(), ad.value.network(), lossReasonLowerThanHighestPrice)
12        }
13    }
14
15    // отмечаем победителя
16    ad.value.win(ad.value.bid(), ad.value.network())
17
18    // показываем рекламу
19    ad.value.show(activity)
20}

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

С остальными форматами(rewarded, banner, native) все будет работать точно так же, может только коллбеки будут немного отличаться.

Осталось посмотреть, как как мы будем инициализировать наше SDK:

 1Mediation.init(
 2    applicationContext,
 3    "12345",
 4    adapters = mapOf(
 5        "yandex" to YandexAdsAdapter(),
 6        "mytarget" to MyTargetAdapter(),
 7        "cpa" to CpaAdapter(),
 8        "bigo" to BigoAdapter(),
 9    ),
10)

Метод init принимает идентификатор приложения в сервисе getapp "12345" и мапу с адаптерами.

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

 1interstitial = Interstitial()
 2interstitial?.init("1", callbacks = object : InterstitialCallbacks {
 3    override fun onLoad(ad: InterstitialAdapter) {
 4        Log.d(tag, "on load")
 5    }
 6
 7    override fun onImpression(ad: InterstitialAdapter, data: String) {
 8        Log.d(tag, data)
 9    }
10
11    override fun onFailure(message: String) {
12        Log.e(tag, message)
13    }
14
15    // ...
16})

"1" - это идентификатор плейсмента, который нужно создать и привязать к приложению в сервисе getapp. У этого плейсмента название download-main-interstitial. Я везде в примерах буду использовать этот плейсмент и добавлять к нему все остальные юниты.

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

Теперь можно поговорить о конкретных реализациях адаптеров для разных рекламных сеток и форматов.

Bigo

Начнем с рекламной сетки bigo ads. С большой вероятностью вы слышали про приложение likee - аналог TikTok. Так вот это те самые ребята. Логично, когда у тебя появляется много трафика, можно делать рекламную сетку. У TikTok, кстати, тоже есть своя рекламная сетка.

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

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

Этот ид нужно указать в сервисе getapp.

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

Теперь мы готовы реализовать интерфейс MediationAdapter для сетки bigo. Сделаем это в классе BigoAdapter. В init нужно пробросить параметр key из сервиса getapp:

 1class BigoAdapter : MediationAdapter {
 2    private val tag = "bigo"
 3
 4    override fun init(context: Context, key: String) {
 5        Log.d(tag, "init bigo adapter")
 6
 7        val config = AdConfig.Builder()
 8            .setAppId(key)
 9            .setDebug(true)
10            .build()
11
12        BigoAdSdk.initialize(context, config) {
13            Log.i(tag, "initialized")
14        }
15    }
16
17    override fun token(context: Context): String {
18        return ""
19    }
20
21    override fun createInterstitial(
22        placement: Int,
23        unit: String,
24        callbacks: InterstitialCallbacks
25    ): InterstitialAdapter {
26        return BigoInterstitialAdapter(placement = placement, unit = unit, callbacks = callbacks)
27    }
28}

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

 1class BigoInterstitialAdapter(
 2    private val placement: Int,
 3    private val unit: String,
 4    private val callbacks: InterstitialCallbacks,
 5) : InterstitialAdapter {
 6    private val network = "bigo"
 7
 8    private var interstitial: InterstitialAd? = null
 9    private var bid: AdBid? = null
10
11    private val impressions = ImpressionsService()
12
13    override fun bid(): Double {
14        val price = bid?.price ?: 0.0
15
16        return price
17    }
18
19    override fun load(context: Context) {
20        // ...
21    }
22
23    override fun show(activity: Activity) {
24        interstitial?.show(activity)
25    }
26
27    override fun win(price: Double, bidder: String) {
28        bid?.notifyWin(price, bidder)
29    }
30
31    override fun loss(price: Double, bidder: String, reason: Int) {
32        bid?.notifyLoss(price, bidder, reason)
33    }
34
35    override fun network(): String {
36        return network
37    }
38}

Про метод load расскажу чуть позже. А сейчас обратите внимание на параметры класса.

  • var interstitial: InterstitialAd? = null - это экземпляр класса, который реализует логику интерстишела из самой рекламной сетки bigo.
  • var bid: AdBid? = null - в этом параметре хранится значение ставки, которое мы будем отдавать в методе fun bid(): Double и которое будет использоваться для аукциона.
  • val impressions = ImpressionsService() - это земпляр класса, в котором реализовано логирование показа на бекенде в сервисе getapp.

Для адаптера bigo важно реализовать методы loss и win для правильной работы клиентского биддинга.

Загрузка рекламы и сохранение ставки реализовано в методе load:

 1override fun load(context: Context) {
 2    val ext = JSONObject()
 3    ext.putOpt("mediationName", "getapp"); // необязательные параметры,
 4    ext.putOpt("mediationVersion", "0.0.1"); // которые нужно передать в сетку для 
 5    ext.putOpt("adapterVersion", "0.0.1"); // качественной работы клиентского биддинга
 6
 7    val loader: InterstitialAdLoader = InterstitialAdLoader.Builder()
 8        .withExt(ext.toString())
 9        .withAdLoadListener(object : AdLoadListener<InterstitialAd> {
10            override fun onError(err: AdError) {
11                callbacks.onFailure(err.message)
12            }
13
14            override fun onAdLoaded(ad: InterstitialAd) {
15                // добавляем коллбеки для логирования показов и других событий
16                ad.setAdInteractionListener(object : AdInteractionListener {
17                    override fun onAdError(error: AdError) {
18                        Log.e(tag, error.message)
19                    }
20
21                    override fun onAdImpression() {
22                        scope.launch {
23                            impressions.impression(
24                                placement = placement,
25                                data = ImpressionRequest(
26                                    unit = unit,
27                                    data = "",
28                                    revenue = (bid?.price ?: 0.0) / 1000 // цена одного показа
29                                ),
30                                callback = object : ImpressionHandler {
31                                    override fun onFailure(e: Throwable) {
32                                        Log.e(tag, e.message.toString())
33                                    }
34
35                                    override fun onSuccess() {
36                                        Log.i(tag, "write impression")
37                                    }
38                                }
39                            )
40                        }
41                    }
42
43                    // другие коллбеки пропущены для краткости
44                })
45
46                // тут нам нужно сохранить ставку
47                interstitial = ad
48                bid = ad.bid // cpm
49
50                // и пробросить коллбек загрузки выше
51                callbacks.onLoad(this@BigoInterstitialAdapter)
52            }
53
54        }).build()
55
56    // после добавления нужных коллбеков загружаем стишел
57    val request = InterstitialAdRequest.Builder()
58        .withSlotId(unit)
59        .build()
60
61    loader.loadAd(request)
62}

Обратите внимание, что в fun onAdLoaded(ad: InterstitialAd), кроме добавления новых коллбеков, мы сохраняем значение ad.bid и используем его в методе fun bid(): Double для вычисления самой дорогой ставки в аукционе.

MyTarget

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

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

MyTarget не умеет отдавать стоимость показа во время загрузки рекламного объявления. Поэтому, нам нужно получить ставку из нашего сервиса getapp уже после удачной загрузки рекламного материала. Откуда эти ставки берутся я расскажу в разделе “Как учитывать показы и выбирать победителей”.

Начнем с адаптера для медиации MyTargetAdapter:

 1class MyTargetAdapter: MediationAdapter {
 2    private val tag = "mytarget"
 3
 4    override fun init(context: Context, key: String) {
 5        Log.d(tag, "init mytarget adapter")
 6        MyTargetManager.setDebugMode(true) // это временно, только для дебага
 7        MyTargetManager.initSdk(context)
 8    }
 9
10    override fun token(context: Context): String {
11        return MyTargetManager.getBidderToken(context)
12    }
13
14    override fun createInterstitial(placement: Int, unit: String, callbacks: InterstitialCallbacks): InterstitialAdapter {
15        return MyTargetInterstitialAdapter(placement = placement, unit = unit, callbacks = callbacks)
16    }
17}

MyTargetManager.getBidderToken(context) возвращает токен, который можно использовать в in-app bidding. Когда вы станете большим паблишером, то сможете попробовать уговорить MyTarget интегрироваться по API для биддинга.

 1class MyTargetInterstitialAdapter(
 2    private val placement: Int,
 3    private val unit: String,
 4    private val callbacks: InterstitialCallbacks,
 5) : InterstitialAdapter {
 6
 7    private var cpm: Double = 0.0
 8    private var bid: Double = 0.0
 9
10    private var interstitial: InterstitialAd? = null
11
12    private val auction = AuctionService()
13    private val impressions = ImpressionsService()
14
15    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
16
17    override fun bid(): Double {
18        return bid
19    }
20
21    override fun load(context: Context) {
22
23        interstitial = InterstitialAd(unit.toInt(), context)
24        interstitial?.isMediationEnabled = true
25        interstitial?.setListener(object : InterstitialAdListener {
26            override fun onLoad(ad: InterstitialAd) {
27                scope.launch {
28                    auction.bid(placement, BidRequest(
29                        unit = unit,
30                        user = User(id = "1")
31                    ), object : BidHandler {
32                        override fun onFailure(e: Throwable) {
33                            Log.e(tag, e.message.toString())
34                        }
35
36                        override fun onSuccess(resp: BidResponse) {
37                            cpm = resp.cpm
38                            bid = resp.bid
39                        }
40                    })
41                }
42
43                callbacks.onLoad(this@MyTargetInterstitialAdapter)
44            }
45
46            override fun onNoAd(reason: IAdLoadingError, ad: InterstitialAd) {
47                Log.e(tag, reason.message)
48                callbacks.onFailure(reason.message)
49            }
50
51            override fun onDisplay(ad: InterstitialAd) {
52                scope.launch {
53                    impressions.impression(
54                        placement = placement,
55                        data = ImpressionRequest(
56                            unit = unit,
57                            data = "",
58                            revenue = cpm / 1000
59                        ),
60                        callback = object : ImpressionHandler {
61                            override fun onFailure(e: Throwable) {
62                                Log.e(tag, e.message.toString())
63                            }
64
65                            override fun onSuccess() {
66                                Log.i(tag, "write impression")
67                            }
68                        }
69                    )
70                }
71
72                callbacks.onImpression(this@MyTargetInterstitialAdapter, "")
73            }
74        })
75
76        interstitial?.load()
77    }
78}

В этом классе метод load сильно отличается от аналогичного в адаптере для стишела bigo. Давайте пройдемся по шагам.

Для начала, создаем экземпляр класса InterstitialAd с указанием какой юнит нужно использовать:

1interstitial = InterstitialAd(unit.toInt(), context)

И сразу после добавляем коллбеки начиная с onLoad:

 1interstitial?.setListener(object : InterstitialAdListener {
 2  override fun onLoad(ad: InterstitialAd) {
 3      scope.launch {
 4          // получаем ставку от сервиса getapp
 5          auction.bid(placement, BidRequest(
 6              unit = unit,
 7              user = User(id = "1")
 8          ), object : BidHandler {
 9              override fun onFailure(e: Throwable) {
10                  Log.e(tag, e.message.toString())
11              }
12
13              override fun onSuccess(resp: BidResponse) {
14                  cpm = resp.cpm
15                  bid = resp.bid
16              }
17          })
18      }
19
20      // пробрасываем выше
21      callbacks.onLoad(this@MyTargetInterstitialAdapter)
22  }
23
24  // ...
25}

Метод auction.bid() реализован в сервисе val auction = AuctionService() и ходит в ручку API сервиса getapp. Эта ручка отдает структуру BidResponse:

1data class BidResponse(
2    val unit: String,
3    val bid: Double,
4    val cpm: Double,
5)

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

Как логировать показы можно посмотреть в реализации коллбека un onDisplay(ad: InterstitialAd):

 1override fun onDisplay(ad: InterstitialAd) {
 2    scope.launch {
 3        impressions.impression(
 4            placement = placement,
 5            data = ImpressionRequest(
 6                unit = unit,
 7                data = "",
 8                revenue = cpm / 1000
 9            ),
10            callback = object : ImpressionHandler {
11                override fun onFailure(e: Throwable) {
12                    Log.e(tag, e.message.toString())
13                }
14
15                override fun onSuccess() {
16                    Log.i(tag, "write impression")
17                }
18            }
19        )
20    }
21
22    callbacks.onImpression(this@MyTargetInterstitialAdapter, "")
23}

Важно, что в метод mpressions.impression() мы передаем цену за один показ cpm / 1000.

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

Яндекс

Создаем рекламный блок в админке Яндекса и копируем его идентификатор.

Создаем новый юнит для плейсмента download-main-interstitial и указываем там идентификатор юнита из Яндекса R-M-2581097-6.

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

Пропустим весь лишний код и сразу перейдем к методу load:

 1override fun load(context: Context) {
 2    loader = InterstitialAdLoader(context).apply {
 3        setAdLoadListener(object : InterstitialAdLoadListener {
 4            override fun onAdLoaded(ad: InterstitialAd) {
 5                scope.launch {
 6                    auction.bid(placement, BidRequest(
 7                        unit = unit,
 8                        user = User(id = "1")
 9                    ), object : BidHandler {
10                        override fun onFailure(e: Throwable) {
11                            Log.e(tag, e.message.toString())
12                        }
13
14                        override fun onSuccess(resp: BidResponse) {
15                            cpm = resp.cpm
16                            bid = resp.bid
17                        }
18                    })
19                }
20
21                ad.setAdEventListener(object : InterstitialAdEventListener {
22                    override fun onAdImpression(data: ImpressionData?) {
23                        scope.launch {
24                            impressions.impression(
25                                placement = placement,
26                                data = ImpressionRequest(
27                                    unit = unit,
28                                    data = data?.rawData.orEmpty(),
29                                    revenue = cpm / 1000
30                                ),
31                                callback = object : ImpressionHandler {
32                                    override fun onFailure(e: Throwable) {
33                                        Log.e(tag, e.message.toString())
34                                    }
35
36                                    override fun onSuccess() {
37                                        Log.i(tag, "write impression")
38                                    }
39                                }
40                            )
41                        }
42
43                        // пробрасываем дальше
44                        callbacks.onImpression(this@YandexAdsInterstitialAdapter, data?.rawData.orEmpty())
45                    }
46                })
47
48                interstitial = ad
49                callbacks.onLoad(this@YandexAdsInterstitialAdapter)
50            }
51
52            override fun onAdFailedToLoad(adRequestError: AdRequestError) {
53                Log.d(tag, adRequestError.description)
54
55                callbacks.onFailure(adRequestError.description)
56            }
57
58            // ...
59        })
60    }
61
62    loadInterstitial()
63}

В методе fun onAdLoaded(ad: InterstitialAd) все похоже на прошлую реализацию. Но вот в логировании показа есть дополнительный параметр data: ImpressionData?.

 1override fun onAdImpression(data: ImpressionData?) {
 2    scope.launch {
 3        impressions.impression(
 4            placement = placement,
 5            data = ImpressionRequest(
 6                unit = unit,
 7                data = data?.rawData.orEmpty(),
 8                revenue = cpm / 1000
 9            ),
10            callback = object : ImpressionHandler {
11                override fun onFailure(e: Throwable) {
12                    Log.e(tag, e.message.toString())
13                }
14
15                override fun onSuccess() {
16                    Log.i(tag, "write impression")
17                }
18            }
19        )
20    }
21
22    // пробрасываем дальше
23    callbacks.onImpression(this@YandexAdsInterstitialAdapter, data?.rawData.orEmpty())
24}

В параметре data: ImpressionData? приходит JSON из которого можно получит актуальное значение цены показа. На бекенде его распарсим в структуру:

 1type YandexData struct {
 2	Currency   string `json:"currency"`
 3	RevenueUSD string `json:"revenueUSD"`
 4	Precision  string `json:"precision"`
 5	Revenue    string `json:"revenue"`
 6	RequestID  string `json:"requestId"`
 7	BlockID    string `json:"blockId"`
 8	AdType     string `json:"adType"`
 9	AdUnitID   string `json:"ad_unit_id"`
10	Network    struct {
11		Name     string `json:"name"`
12		Adapter  string `json:"adapter"`
13		AdUnitID string `json:"ad_unit_id"`
14	} `json:"network"`
15}

RevenueUSD - нужное нам поле. Для удобства пытаемся конверттировать всю валюту в USD. Так будет проще вести все расчеты.

Как учитывать показы и выбирать победителей

В примерах интеграции выше я постоянно говорю про необходимость учитывать показы и их стоимость. Это необходимо для рассчета eCPM для разных юнитов разных рекламных сеток.

Учитываем показы

Для расчета eCPM можно использовать два подхода:

  • Записывать показ с учетом цены и расчитвать eCPM раз в несколько часов. Вычисляем среднюю цену одного показа и умножаем ее на 1000.
  • Получать статистику от реламной сетки по API и уже эту статистику использовать для расчета eCPM.

Ручка, которая принимает информацию для сохранения показа

1POST {{host}}/v1/mediation/impressions/1/impression
2Accept: application/json
3Content-Type: application/json
4
5{
6  "data": "",
7  "revenue": 0.0021,
8  "unit": "example"
9}

Стоимость показа приходит в поле revenue и мы используем это значение для всех юнитов, кроме Яндекс. Для Яндекс приходит заполненное поле data, в котором указано реальное значение стоимости показа и именно его используем в качестве revenue.

Все показы будем сохранять в табличку impressions

 1create table public.impressions (
 2  id bigint primary key not null default nextval('impressions_id_seq'::regclass),
 3  placement_id bigint,
 4  network_id bigint,
 5  unit_id bigint,
 6  date timestamp with time zone,
 7  revenue numeric,
 8
 9  created_at timestamp with time zone,
10  updated_at timestamp with time zone,
11  deleted_at timestamp with time zone
12);
  • date - время учета показа.
  • revenue - цена одного показа, будем использовать для расчета средней цены показа.

Расчет eCPM

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

 1create table public.cpms (
 2  placement_id bigint not null,
 3  network_id bigint not null,
 4  unit_id bigint not null,
 5  date timestamp with time zone not null,
 6  amount numeric,
 7
 8  created_at timestamp with time zone,
 9  updated_at timestamp with time zone,
10  deleted_at timestamp with time zone,
11
12  primary key (placement_id, network_id, unit_id, date)
13);
  • date - время обновления eCPM, указывается с точностью до часа.
  • amount - это уже расчетное значение 1000 показов.

Используем разный подход к расчету eCPM для разных сеток. Для MyTarget нам нужно загрузить данные из API статистики. Для этого делаем запрос:

1GET https://target.my.com/api/v2/statistics/geo/pads/hour.json?id=2094672&date_from=2024-01-21&date_to=2024-01-23
2Authorization: Bearer {{token}}

В результате мы получим довольно замудренный JSON из которого можно получить кол-во показов и и заработанные деньги за указанны период. И само значение CPM тоже приходит в ответе.

 1type Data struct {
 2	Clicks           int     `json:"clicks"`
 3	Shows            int     `json:"shows"`
 4	Goals            int     `json:"goals"`
 5	Noshows          int     `json:"noshows"`
 6	Requests         int     `json:"requests"`
 7	RequestedBanners int     `json:"requested_banners"`
 8	ResponsedBlocks  int     `json:"responsed_blocks"`
 9	ResponsedBanners int     `json:"responsed_banners"`
10	Amount           string  `json:"amount"`
11	Responses        int     `json:"responses"`
12	Cpm              string  `json:"cpm"`
13	Ctr              int     `json:"ctr"`
14	FillRate         float64 `json:"fill_rate"`
15	ShowRate         float64 `json:"show_rate"`
16	Vtr              int     `json:"vtr"`
17	Vr               int     `json:"vr"`
18}

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

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

Для Яндекс или Bigo нам не обязательно ходить за статистикой в API. Нам нужна только табличка impressions, из которой мы получим все показы и их стоимость за нужный период. По жтим данным рассчитаем среднюю стоимость одного показа, умножим на 1000 и сохраним в табличку cpms актуальный eCPM. Этот подход проще, чем запросы в API статистики, но, при необходимости, все можно переделать на работу по API.

Делаем ставку

Для сеток, которые не умеют отдавать ставку сами при загрузке баннера, мы будем использовать eCPM в роли ставки. Пример запроса ставки для MyTarget:

 1POST {{host}}/v1/mediation/auction/1/bid
 2Accept: application/json
 3Content-Type: application/json
 4
 5{
 6  "unit": "1499758",
 7  "user": {
 8    "id": "1"
 9  }
10}
  • "1499758" - это слот из кабинета MyTarget по которому мы получаем рекламу в приложении и который используем для расчета eCPM.
  • "user" - пример дополнительной информации, которая может понадобиться в будущем для получения максимально актуальной ставки.

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

1{
2  "unit": "1499758",
3  "cpm": 0.001,
4  "bid": 0.001
5}

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

Даем дорогу новичкам

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

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

Идея довольно простая. Представим игральный автомат типа “однорукий бандит”: кидаешь монетку, дёргаешь ручку и иногда (с заданной вероятностью, распределением, вероятностным правилом или законом) получаешь выигрыш, большой или не очень, — всё зависит от того, насколько жаден владелец автомата.

Про сам алгортм можно подробно почитать тут или тут

Я реализовал что-то похожее на бандита в методе Bandit:

 1func (b *Bidding) Bandit(bid float64, unit models.Unit) (float64, error) {
 2	to := time.Now()
 3	from := to.Add(-time.Hour * 24 * 3) // за последние 3 дня
 4
 5	// Сначала выбираем список средних CPM по сеткам.
 6	// На самом деле, тут нужно знать что мы поставили
 7	// в запросах по другим сеткам. Но так тоже подойдет.
 8	// Этого достаточно, что бы понять, что текущий запрос по сетке,
 9	// которая пока мало выигрывает
10	cmpsByNetwork, err := b.networks.CpmsByNetwork(from, to)
11
12	if err != nil {
13		return 0, err
14	}
15
16	sort.Slice(cmpsByNetwork, func(i, j int) bool {
17		return cmpsByNetwork[i].Cpm > cmpsByNetwork[j].Cpm
18	})
19
20	// запрос по самой дорогой сетке, тут ничего не тестим, отдаем как есть
21	if cmpsByNetwork[0].Network == unit.NetworkId {
22		return bid, nil
23	}
24
25	// только одна сетка в списке
26	n := len(cmpsByNetwork)
27	if n <= 1 {
28		return bid, nil
29	}
30
31  // рассчитываем, с какой вероятностью мы 
32  // додлжны показывать сетки неудачники
33	p := e / float64(n-1)
34
35  // бросаем монетку
36	coin := rand.Float64()
37
38	if coin < p {
39    // и отдаем очень большое значение ставки, 
40    // если монетка выиграла 
41		return bid + addition, nil
42	}
43
44	return bid, nil
45}

Два важных параметра этого алгоритма:

  • e - эпсилон, процент показов, который мы готовы отдать на тесты. Например, мы готовы отдать 15% на тестирование проигрывающих сеток. Тогда для каждой сетки нужно отдавать 5% и это рассчитывается в строке p := e / float64(n-1), где n - это кол-во рекламных сеток на тестирование
  • addition - большое число, которое нужно добавить к ставке чтобы она победила все ставки от других сеток и получила показ.

Своя рекламная сетка

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

Рекламу можно брать из CPA сеток, например gdeslon.ru или admitad.ru. Там есть ссылки и готовые рекламные материалы.

Но это большая тема уже для отдельной статьи.

Заключение

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

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

Если вы хотите попробовать медиацию в своем приложении - напишите мне в телегу @akovardin и я помогу вам все настроить и потестировать.

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

Ссылки

comments powered by Disqus