Бот на Playwright, который присылает в Telegram топ доходностей в DeFi

Каждый день я заходил на десяток сайтов, чтобы найти актуальный APY для стейблкоинов. Aave, Morpho, Pendle, Euler, Kamino - у каждого свой интерфейс, свои фильтры, свои метрики. Спустя неделю это перестало быть анализом и стало рутиной. Написал бота, который делает это за меня - и теперь вижу возможности раньше, чем успеваю открыть браузер.

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

Каждое утро и каждый вечер в Telegram приходит вот такое сообщение (данные иллюстративные - реальные ставки меняются ежедневно):

🏆 Top USD Yields
──────────
1.  USDe/Pendle (Ethereum) -  12.30% | $450M
2.  USDC/Morpho (Base) -  8.75% | $120M
3.  USDT/Aave (Arbitrum) -  6.20% | $890M
...
──────────
📅 23.02.2026

Топ-10 доходностей по стейблкоинам. Только пулы с TVL выше миллиона долларов - всё что ниже я отфильтровал как неликвидное. В списке USDC, USDT, USDe, DAI, USDS и другие стейблкоины. Протоколы - Aave, Compound, Fluid, Morpho, Paradex, Kamino, Pendle, Euler.

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

Как это устроено внутри

Стек простой: Bun как рантайм, Playwright для скрейпинга, grammY для Telegram, Croner для расписания.

Bun - это замена Node.js с нативной поддержкой TypeScript. Не нужно компилировать - просто пишешь .ts файл и запускаешь. Стартует быстрее.

Playwright - библиотека для управления браузером. Именно браузером, а не HTTP-клиентом. DeFi-протоколы - это React-приложения, которые рендерятся на клиенте. Без браузера ты видишь пустую HTML-страницу.

grammY - TypeScript-first фреймворк для Telegram-ботов. Выбрал его за типизацию и простоту.

Croner - библиотека для cron-расписаний без зависимостей. Работает с Bun из коробки.

Поток данных выглядит так:

[Croner: 09:00 и 21:00]
        ↓
[Playwright: открывает 8 протоколов, собирает APY]
        ↓
[Фильтр: только стейблкоины, TVL > $1M, топ-10 по APY]
        ↓
[grammY: отправляет в Telegram]

Расписание в коде:

const job = new Cron("0 9,21 * * *", collect)
startBot(collect)
console.log("Next collection:", job.nextRun())

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

Почему Playwright, а не API

Часть протоколов имеет публичные API - Aave, Compound, Morpho. Я пробовал начать с них. Но Pendle не предоставляет удобного публичного эндпоинта для APY. Kamino - тоже. Пришлось скрейпить интерфейс.

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

Проблема всех DeFi-сайтов: они определяют ботов. Headless-браузер вычисляется по сигнатурам - navigator.webdriver, отсутствие Chrome-объектов, странные параметры viewport. Решение: запускать браузер в headed-режиме (с видимым окном), но на виртуальном дисплее. На сервере это делается через xvfb-run:

xvfb-run bun run index.ts

Браузер думает, что у него есть экран. Сайт думает, что это обычный пользователь.

Код запуска:

export async function createBrowser(): Promise<Browser> {
  return chromium.launch({
    headless: false,
    proxy: process.env.PROXY_URL ? { server: process.env.PROXY_URL } : undefined,
  })
}

Дополнительно - случайные задержки между запросами и ожидание полной загрузки страницы перед извлечением данных (waitUntil: 'networkidle').

Вот как выглядит discovery рынков на примере Aave - бот сначала открывает дропдаун со списком рынков, собирает их, закрывает:

async function discoverMarkets(page: Page): Promise<MarketInfo[]> {
  await page.click('div[role="combobox"]', { timeout: 5000 })
  const marketOptions = await page.$$eval('li[data-cy^="marketSelector_"]', (options) => {
    return options
      .filter(opt => {
        const rect = opt.getBoundingClientRect()
        return rect.width > 0 && rect.height > 0
      })
      .map(opt => ({
        dataCy: opt.getAttribute('data-cy') || '',
        displayName: opt.textContent?.trim() || ''
      }))
  })
  await page.keyboard.press('Escape')
  return marketOptions
}

Что шло не так

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

Telegram API иногда возвращает 429 - слишком много запросов. Обработал через exponential backoff: первая попытка, потом пауза, потом ещё попытка. На практике это случается редко, но без обработки бот просто молчит, и ты не понимаешь почему.

Самое неожиданное - xvfb. Локально бот работал отлично. На VPS без виртуального дисплея - нет. Chromium не запускается без экрана в headed-режиме. Час потерял на отладку, пока не дошло. Теперь это первое, что проверяю при деплое.

С чего начать

Если хочешь сделать что-то похожее, вот минимальный путь:

Что нужно:

  • Bun (или Node.js 20+)
  • Telegram Bot Token (через @BotFather)
  • Час времени на прототип

Шаги:

  1. Установить зависимости: bun add playwright grammy croner
  2. Написать функцию, которая открывает нужный сайт и ждёт загрузки
  3. Найти CSS-селектор нужного элемента через DevTools браузера
  4. Извлечь текст, распарсить число
  5. Отправить в Telegram через grammY

Базовый запуск Playwright выглядит так:

import { chromium } from "playwright"

const browser = await chromium.launch({ headless: false })
const page = await browser.newPage()
await page.goto("https://app.aave.com", { waitUntil: "networkidle" })
// дальше извлекаешь нужные данные
await browser.close()

Документация: playwright.dev, grammy.dev, croner.56k.guru. Всё подробно и с примерами.

Если нужны готовые решения для DeFi-данных - есть Apify Actors (DefiLlama Scraper, DeFi Yield Finder). Для быстрого старта подходит, но теряешь гибкость: не можешь добавить произвольный протокол и настроить фильтры под себя.

Что дальше

Бот работает стабильно несколько недель. Я больше не открываю вручную десяток вкладок - просто смотрю на уведомление в Telegram.

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

Ещё вариант: сохранять историю APY в базу и смотреть тренды. Но это уже другая статья.

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