July 2, 2023

Схемный трафик (ч. 2): Построение командной структуры на основе связки Keitaro + PuzzleBot 

В предыдущей статье (ч. 1) мы разобрали как связать партнерку => Keitaro => PuzzleBot в единую "триаду" для построения автоворонок внутри Telegram. Уверен, многие посыпались от технических сложностей и по итогу забили на подобный способ пролива. Возможно кого-то не убедили плюсы высказанные в пользу бота и все как лили на тг-каналы, так и льют.

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

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

Плюсы тг-ботов для овнеров:

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

Плюсы тг-ботов для обработчиков:

  • Легкая, интуитивно-понятная запись депа к себе в стату обработчиком. Больше не надо просить юзера прислать его ID на платформе казино, у нас вся привязка осуществляется по chat_id юзера в Telegram
  • Легкая доступность статистики для обработчика через персонального бота обработчика + автоматический рассчет выплаты / прямого доступа в Кейтаро (по усмотрению овнера)

Общие минусы:

  • Всех юзеров надо регистрировать через специальную ссылку на бота с параметром ?start=имя_обраба. Не обязательно заливать конкретно на бота, можно и на канал, но ссылка с канала должна вести на бота со специальным параметром, чтобы бот сразу же скидывал ссылку для регистрации юзеру, вместо обычных постов на команде start

Объяснение реализации

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

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


Требования

- Наличие редакции Keitaro с admin api
- Базовое понимание работы трекера
- Базовое понимание кода и работы с python

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

1. У меня код стоит на винде, поэтому и рассказывать я буду про винду. Для начала нам надо накатить python 3й версии. Далее устанавливаем переменные среды для удобной работы. После этого открываем cmd и ставим библиотеки requests и aiogram следующими командами:
- pip install requests
- pip install aiogram

2. Когда пайтон и библиотеки установлены, можно приступить к настройке кода. Создаем папку в любом месте системы и в ней создаем текстовик с содержимым указанным ниже. Далее файл нужно будет переименовать в .py вместо .txt.

Код приведенный ниже полностью написан с помощью Chat GPT точечными запросами. Т.е я писал его не целиком, а именно блочно, соединяя потом эти блоки воедино. По сути это демонстрирует возможности, которые открываются буквально всем, было бы время и желание в этом развиваться. Поэтому enjoy, можно писать все что угодно для командной структуры затрачивая минимум времени.
import requests
from aiogram import Bot, Dispatcher, types
from aiogram.contrib.fsm_storage.memory import MemoryStorage
from aiogram.dispatcher import FSMContext
from aiogram.dispatcher.filters import Command
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
import datetime
import os

# ==================== CONFIG ====================
# Настройки телеграм
bot_token = 'from_botfather_token' # Тут указываем токен бота созданного в @botfather_bot

# Настройки кейтаро
kt_address = 'https://domain.com' # Необходимо указать домен привязанный в Кейтаро на ssl сертификате (обязательно!)
kt_token = 'keitaro_token' # Получаем админский токен от Кейтаро тут - keitarodomain.com/admin/#!/profile/admin_api_keys
kt_timezone = 'Europe/Moscow' # От часового пояса будет зависеть разбивка статы у обработчика

# Внутренние переменные бота
admin = '@admin' # Здесь необходимо указать ник юзера, отвечающего за тех. обслуживание ботов для обработчиков. В случае ошибки, обработчик будет знать кому переслать ошибку выданную ботом
chat_id = '' # Оставляем пустым, не обращаем внимание
handler_name = 'az' # Это метка обработчика, которая будет записываться в Кейтаро. Не указывайте длинные метки, пусть лучше будут 2-3 символа
aff_links = [{'Grecia - GR':'https://my.links'}] # Тут можно указывать список ссылок в формате json array. Все эти ссылки будут присылаться обработчику после вызова команды /links. Бот будет подставлять метку handler_name в каждую из ссылок

# Путь к файлу с логами chat_id
chat_ids_file = 'chat_ids.txt' # Если для вас название файла лога не имеет значение, оставляем все как есть
# ==================== CONFIG ====================

# Инициализируем бота и диспетчера
bot = Bot(token=bot_token)
storage = MemoryStorage()
dp = Dispatcher(bot, storage=storage)

# Подпись в консоли чей бот
print(f'============== {handler_name} ==============')

# Проверка наличия chat_id в файле
def check_chat_id(chat_id):
    if os.path.isfile(chat_ids_file):
        with open(chat_ids_file, 'r') as file:
            return str(chat_id) in file.read()
    else:
        return False

def clear_text_file():
    # Открытие файла в режиме записи (существующий файл будет перезаписан)
    with open(chat_ids_file, 'w') as file:
        # Запись пустой строки в файл
        file.write('')

# Запись chat_id в файл
def write_chat_id(chat_id):
    with open(chat_ids_file, 'a') as file:
        file.write(str(chat_id) + '\n')

def calculate_payment(client_count):
    if client_count <= 15:
        payment = 1 * client_count
    elif client_count <= 30:
        payment = (1 * 15) + (2 * (client_count - 15))
    elif client_count <= 45:
        payment = (1 * 15) + (2 * 15) + (3 * (client_count - 30))
    else:
        payment = (1 * 15) + (2 * 15) + (3 * 15) + (3 * (client_count - 45))

    return payment

periods = [
    {"today": "За сегодня"},
    {"yesterday": "За вчера"},
    {"last_monday": "Текущая неделя"},
    {"7_days_ago": "За последние 7 дней"},
    {"first_day_of_this_month": "Текущий месяц"},
    {"previous_month": "Предыдущий месяц"},
    {"1_month_ago": "За последние 30 дней"}
]

# Обработчик команды /stats
@dp.message_handler(Command('stats'))
async def process_stats_command(message: types.Message, state: FSMContext):
    # Отправляем сообщение с просьбой ввести текст
    await message.reply("Форматы дат: \n"+'\n'.join([f"{list(period.keys())[0]}: {list(period.values())[0]}" for period in periods]))

    # Устанавливаем состояние 'answer' для данного пользователя
    await state.set_state('answer')

# Обработчик ответа с текстом
@dp.message_handler(state="answer", content_types=types.ContentTypes.TEXT)
async def process_text_answer(message: types.Message, state: FSMContext):
    # Получаем текст из ответа
    interval = message.text
    
    if any(interval in item for item in periods):
        body = {
          "range": {
            "interval": interval,
            "timezone": kt_timezone
          },
          "columns": [
            "datetime",
            "campaign",
          ],
          "metrics": [],
          "grouping": [],
          "filters": [
            {
              "name": "is_sale",
              "operator": "IS_TRUE",
              "expression": None
            },
            {
              "name": "sub_id_7",
              "operator": "CONTAINS",
              "expression": handler_name
            }
          ],
          "sort": [
            {
              "name": "datetime",
              "order": "desc"
            }
          ],
          "summary": True,
          "limit": 500,
          "offset": 0
        }

        result = requests.post(f'{kt_address}/admin_api/v1/clicks/log', json=body, headers={'Api-key': kt_token})

        if(result.status_code == 200):
            json = result.json()
            
            results = {}

            for row in json['rows']:
                campaign = row['campaign']
                click_datetime = row['datetime']
                click_date = datetime.datetime.strptime(click_datetime, '%Y-%m-%d %H:%M:%S').strftime('%Y-%m-%d')
                
                if campaign not in results:
                    results[campaign] = {}
                
                if click_date not in results[campaign]:
                    results[campaign][click_date] = 0 
                
                results[campaign][click_date] += 1

            # Сортировка результатов по дате
            sorted_results = {}
            for campaign, dates in results.items():
                sorted_dates = dict(sorted(dates.items(), key=lambda x: datetime.datetime.strptime(x[0], '%Y-%m-%d')))
                sorted_results[campaign] = sorted_dates

            # Форматированный вывод результатов
            for campaign, dates in sorted_results.items():
                output = f"Campaign: {campaign}\n"
                summ_sales = 0
                summ_payout = 0
                
                for date in dates.items():
                    sales = date[1]
                    payout = calculate_payment(date[1])
                    
                    summ_sales += sales
                    summ_payout += payout
                    
                    output += f"  {date[0]}:\n"
                    output += f"    Sales: {sales}\n"
                    output += f'    Payout: {payout}$\n\n'
                
                output += f'------------\nTotal:\nSales: {summ_sales}\nPayout: {summ_payout}#39;
                await message.reply(output)
        else:
            print(f'Ошибка запроса {result.status_code}')
    else:
        await message.reply('Неверный формат даты')

    # Сбрасываем состояние 'answer' для данного пользователя
    await state.finish()


@dp.message_handler(Command('links'))
async def links(message: types.Message):
    for link in aff_links:
        key = next(iter(link.keys()))
        await bot.send_message(message.chat.id, f'{key}:')
        await bot.send_message(message.chat.id, f'{link[key]}?start={handler_name}')

# Обработчик команды /help
@dp.message_handler(Command('help'))
async def start(message: types.Message):
    await message.reply(f'''
/help - получить список команд
/links - получить список твоих ссылок
/stats - получить статистику
''')

# Обработчик команды /clear_log
@dp.message_handler(Command('clear_log'))
async def clear_log(message: types.Message):
    clear_text_file()
    await message.reply(f'Файл лога {chat_ids_file} очищен')

# Обработчик пересланных сообщений
@dp.message_handler(content_types=types.ContentType.TEXT, is_forwarded=True)
async def forwarded_message(message: types.Message, state: FSMContext):
    global chat_id
    if message.forward_from:
        chat_id = str(message.forward_from.id)

        if not check_chat_id(chat_id):
            write_chat_id(chat_id)
            await message.reply(f'Запрос на запись юзера {chat_id} на ваше имя {handler_name} в обработке ⚙️') #message.chat.id, 

            # Выполнение указанного кода с тремя попытками
            # tries = 5
            try:
            # for i in range(tries):
                # print(f'Попытка получить данные из трекера по chat_id {chat_id} №{i}')
            
                data = {
                    "range": {
                        "interval": "1_month_ago",
                        "timezone": kt_timezone
                    },
                    "columns": [
                        "sub_id"
                    ],
                    "metrics": [],
                    "grouping": [],
                    "filters": [
                        {
                            "name": "sub_id_9",
                            "operator": "CONTAINS",
                            "expression": chat_id
                        }
                    ],
                    "sort": [
                        {
                            "name": "datetime",
                            "order": "desc"
                        }
                    ],
                    "summary": True,
                    "limit": 250,
                    "offset": 0
                }

                response = requests.post(f'{kt_address}/admin_api/v1/clicks/log', json=data, headers={"Api-Key": kt_token})
                
                # success = False
                
                if response.status_code == 200:
                    rows = response.json().get('rows', [])
                    print(f'Ответ на получение subid\'s по chat_id {chat_id}: {rows}')
                    
                    if len(rows) > 0:
                        completed_subids = []
                    
                        for row in rows:
                            subid = row.get('sub_id')
                              
                            print(f'Началась обработка {subid}')
                            
                        # for _ in range(tries):
                            result = requests.get(f'{kt_address}/?_update_tokens=1&sub_id={subid}&sub_id_7={handler_name}')
                            if result.status_code == 200:
                                completed_subids.append(subid)
                                print(f'Запрос по обновление токенов по {subid} обработан успешно')
                                
                                # break
                            else: 
                                await bot.send_message(message.chat.id, f'Запрос на обновление токена по {subid} завершился ошибкой ❌. Перешли эту ошибку админу бота {admin}')
                        # break
                        
                        completed_subids_str = '\n'.join(completed_subids)
                        await bot.send_message(message.chat.id, f'Все {len(completed_subids)+1} кликов юзера {chat_id} записаны на {handler_name}✅\r\n\r\nСписок subid\'s:\r\n{completed_subids_str}')
                    else:
                        # success = True
                        print(f'Юзер с id {chat_id} не найден в системе трекера')
                        await bot.send_message(message.chat.id, f'Юзер с id {chat_id} не найден в системе трекера 📔. Необходимо чтобы этот юзер был зарегистрирован по одной из трекинговых ссылок. Полный список ссылок по команде /links')
                else:
                    print(f'Не удалось получить данные от keitaro по chat_id {chat_id}. Status code: {response.status_code}')
                    
                # if success:
                    # break
            except Exception as e:
                await bot.send_message(message.chat.id, f"Произошла ошибка при выполнении запроса: {str(e)}. Перешли эту ошибку админу бота {admin}")
        else:
            await message.reply(f'Этот chat_id {chat_id} юзера уже присвоен одному из обработчиков 😔') #message.chat.id,
    else:
        await message.reply('Пересланное сообщение не из чата') #message.chat.id, 

# Запускаем бота
if __name__ == '__main__':
    from aiogram import executor
    executor.start_polling(dp, skip_updates=True)

3. Заменяем все данные в переменных в блоке CONFIG, предварительно внимательно прочитав комментарии к каждой переменной (после #)!

Если все сделано правильно, можно запустить код двумя нажатиями на файл, который вы переименовали в .py во 2-ом шаге. Но лучший вариант запустить отдельно cmd, перейти в папку со скриптом командов cd C:\\путь_до_папки_со_скриптом и только потом через команду python code.py запустить скрипт. Так вы сможете видеть ошибки (traceback's), появляющиеся в момент выполнения скрипта.


Нюансы по работе кода:

  • Для работы кода выше, необходимо чтобы юзер переходил на регистрацию по вашей трекинговой ссылке кампании с подставленным параметром keitarodomain.com/?chat_id={{USER_ID_TEXT}} - Такую ссылку должен выдавать ваш бот для регистрации. О том как настроить источник и его параметры в ч.1
  • Источник в кейтаро должен иметь параметр для хранения метки оператора. У меня к примеру это sub_id_7 и код под это заточен. Если вы хотите изменить метку в которой у вас будет храниться параметр с именем оператора, измените его в источнике, затем в кампании и в коде в 266 строчке {kt_address}/?_update_tokens=1&sub_id={subid}&sub_id_7={handler_name} (sub_id_ваш_id_метки)
  • Запись лида на юзера производится в клики Кейтаро, не в конверсии. Т.е мы не можем обновить параметры внутри конверсий, тк. будет повторная отработка s2s если таковые в кампании имеются. Соотв. смотреть стату по конверсиям не получится, но нам доступна стата кампании.
  • Каждый файл с таким кодом = 1 новый бот из botfather + новое название обработчика в переменной handler_name. Простыми словами у каждого обработчика должен быть свой бот, со своими токеном и со своим названием переменной handler_name
  • Все файлы всех ботов должны находиться в одной папке, тк. файл с логом ботов и все боты ориентируются на него. Если расположить их по разным папкам, будет недоступна функция проверки записан ли юзер уже на одного из обработчиков или нет.

Настройка PuzzleBot

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

В конструкторе бота создаем условную конструкцию, которая будет проверять на пустоту переменную start_payload и если она пустая - отрабатывать обычный скрипт бота. Если не пустая - отправлять юзеру сразу текст с ссылкой для регистрации с параметрами, по примеру https://keitaro-campaign-domain.com/?chat_id={{USER_ID_TEXT}}

У меня пост с регистрацией выглядит так:

url_first_play - в этой переменной хранится ссылка на кампанию кейтаро, которая делает редирект уже на оффер
start_payload - в этой метке я передаю метку обработчика. Он кидает юзеру ссылку на бота в таком формате https://t.me/mybot?start=handler, где handler это метка обработчика
user_id_text - это переменная содержащая внутри себя chat_id телеграма этого юзера. Этот id нужен для работы скрипта приведенного выше

Таким образом из всех этих переменных собирается ссылка для регистрации: https://mydom.com/hanler=az&chat_id=9445674&registred=0

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

Осталось лишь объяснить обработчикам для чего подставлять ?start= к ссылке бота и где брать сслки для залива :) (PS: в боте командой /links, если они там указаны предварительно конечно).

Послесловие

Так выглядит работа правильно настроенного бота. Если не видно что написано ПКМ => открыть изображение в новом окне

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

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

Что будет дальше этой статьи?

Отбив статуса lead в Facebook с помощью Conversion Api

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

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

Немного о спонсорах

Как вы сами убедились из серии статей, Keitaro невероятно эффективный инструмент в работе с трафиком. Как раз сейчас выходит новая версия 10.1, в которой будут значительные улучшения опыта работы с трекером. Советую лично от себя обновиться прямо сейчас. А чтобы обновление не вышло в копеечку, предоставляю вам промо adamblog с 20% скидкой для новых участников. Перейди на зеленую сторону и увеличь свой ROI кратно вместе с Keitaro

Для тех кому интересно, выкладываю список новых функций: ⚡️Интеграции по автоматической передаче расходов с Google Ads и TikTok
⚡️Режим чтения кампаний/лендингов/офферов для отдельных пользователей
⚡️Новые статусы конверсий Reg & Dep для работы с гемблингом
⚡️Новые метрики в отчетах от уникальных кликов (uCPC, uEPC и др)
⚡️Новый редактор лендингов (обновленный функционал и дизайн)
⚡️Совместимость с Bearer token
⚡️Обновленная цветовая гама Dashboard