Технический пост-мортем: что было, что сломалось, и что в итоге собралось.

Контекст: умер xml-river, keykollector появился API Wordstat

Я работаю в контекстной рекламе и аналитике 6 лет. Самым массовым инструментом для сбора семантики в моём окружении был KeyCollector. Сам KeyCollector давно не парсил Wordstat напрямую — он работал через стороннее расширение xml-river, которое проксировало запросы к Wordstat в обход капчи. Когда xml-river в какой-то момент перестал работать, эта связка обвалилась, и у тысяч специалистов одновременно возникла одна и та же проблема: как теперь снимать частотность?

Параллельно у Яндекса появился официальный API Wordstat. Сначала бесплатный, с человеческими лимитами. Я написала десктопную программу на Python (PySide6), которая:

  • авторизуется в Яндексе через OAuth (Implicit Flow),

  • многопоточно обходит дерево запросов вглубь,

  • фильтрует минус-словами,

  • экспортирует CSV. Программу выкатила как платный десктоп с пожизненной лицензией. Лицензия — RSA-подписанная строка в файле рядом с .exe, плюс привязка к железу через хэш disk_serial + cpu_id, плюс триал на 7 дней в реестре Windows.

Важный нюанс: у пользователей не было своих токенов Яндекса. Чтобы получить токен, нужно создать приложение в Яндекс OAuth, дождаться одобрения от поддержки, потом отдельно запросить открытие лимитов на API Wordstat — для маркетолога это полдня геморроя, и я не хотела этим грузить клиентов. Поэтому все ходили в API под моим личным токеном. Стандартный лимит — 1000 запросов в сутки на токен, после переписки с поддержкой мне подняли до 5000.

Это работало. Несколько месяцев.

Что сломалось

5000 запросов в сутки на всю клиентскую базу — это очень мало, как только у тебя становится больше десятка активных пользователей. Клиенты начали массово ловить 429 Too Many Requests: общий пул на токен исчерпывался, и тот, кто запустил парсинг позже всех в этот день, оставался ни с чем.

Я писала в поддержку Яндекса несколько раз. Они отвечали быстро и один раз даже подняли мне квоту — со стандартных 1000 до 5000 запросов в сутки. Дальше — отказ: «потолок исчерпан, ждите нового API». А «новый API» — Wordstat в составе Yandex AI Studio Search API — когда наконец появился, оказался платным: 0,02 ₽ за запрос. Зато с нормальной квотой на пользователя, не общей на всех.

Стало ясно, что модель «один общий токен на всех» доживает дни.

Проблема была не в том, что API стал платным. Проблема была в модели взаимодействия:

  • В старой схеме все мои клиенты сидели на одном токене с одним общим лимитом. Я платила Яндексу 0 ₽ (бесплатно), но клиенты дрались за квоту между собой и регулярно получали 429. Дать каждому клиенту свой токен я не могла — это требует от него создания OAuth-приложения, ожидания одобрений и общения с поддержкой Яндекса. Для маркетолога это полдня работы, и продукт «купите программу, потом полдня настраивайте» никто бы не покупал.

  • В новой схеме «оплата за запрос» можно было бы перевести каждого клиента на свою квоту в Яндекс.Облаке, но это требует ещё больше шагов: завести облако, привязать карту, получить API-ключ. То есть проблема «слишком сложно для маркетолога» только усилилась. Логичный выход — самой стать прослойкой между клиентом и API. Я держу API-ключ Яндекса у себя на сервере, оплачиваю запросы из своего кармана, а клиент покупает у меня квоту в рублях: «10 000 запросов за 700 ₽». Никакого OAuth, никаких облаков, никаких 429 от соседей по токену. У каждого клиента — свой лимит, и он гарантированно его получит.

Звучит просто. На деле — это переписать всё: бэк, лицензии, бот в Telegram, лендинг, оплаты. Я положила на это неделю и сделала.

Архитектура: было и стало

Было:

[программа на компе] ──общий токен──> [API Wordstat]
       ▲
       │
   [OAuth: моё приложение, общий токен на всех клиентов]

Программа держала всё: общий OAuth-токен Яндекса (захардкожен в приложении), проверку лицензии (RSA в файле), триал (запись даты установки в реестр Windows + fingerprint железа). Один токен — один общий пул на 5000 запросов в сутки на всех клиентов сразу.

Стало:

[программа] ──License: <token>──> [Caddy/HTTPS] ──> [FastAPI прокси] ──API-Key──> [Yandex AI Studio]
                                                          │
                                                          ▼
                                                    [SQLite: лицензии,
                                                     устройства, квоты,
                                                     валютный учёт]

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

  • Authorization: License WDD-xxxxxxxx-xxxxxxxx — токен лицензии,

  • X-Device-Fingerprint: <sha256> — отпечаток железа. Прокси проверяет, что лицензия валидна, не исчерпана, не заблокирована, и что устройство привязано (или есть свободный слот, чтобы привязать). Если всё ок — пересылает запрос в Яндекс с моим серверным API-ключом, тратит 0,02 ₽ из своей квоты, списывает один запрос у клиента, возвращает ответ.

У каждого клиента теперь своя квота, и она не зависит от других. Никакого пользовательского OAuth, никаких клиентских ключей Яндекса, никаких 429 от соседей по токену.

Самое интересное под капотом

Лицензии без подписей и сертификатов

Старая схема с RSA-подписью в файле — это правильно для оффлайн-приложения, которое не имеет связи с сервером. Когда появляется свой сервер, всё это становится не нужно. Лицензия — просто строка в базе:

CREATE TABLE licenses (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    token           TEXT UNIQUE NOT NULL,    -- WDD-xxxxxxxx-xxxxxxxx
    customer        TEXT,
    plan            TEXT,                    -- start / pro / max / trial
    quota_total     INTEGER,
    quota_used      INTEGER DEFAULT 0,
    active_until    TEXT,                    -- ISO date
    is_blocked      INTEGER DEFAULT 0,
    revenue_total   REAL DEFAULT 0,
    revenue_currency TEXT DEFAULT 'RUB',
    revenue_in_rub  REAL DEFAULT 0,
    tg_user_id      INTEGER,
    device_slots    INTEGER DEFAULT 3,
    is_trial        INTEGER DEFAULT 0,
    note            TEXT,
    created_at      TEXT
);

Чтобы проверить лицензию — SELECT * FROM licenses WHERE token = ? и пара условий. Никакой криптографии не нужно: сам токен и есть «подпись», он живёт только в моей базе.

Это сильно упрощает поддержку. Раньше, чтобы продлить пользователю лицензию, я генерировала новый подписанный файл и просила его положить рядом с программой. Теперь — UPDATE licenses SET active_until = ?, quota_total = quota_total + ?, и через секунду клиент работает дальше с тем же ключом.

Привязка к устройствам

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

CREATE TABLE license_devices (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    license_id   INTEGER NOT NULL,
    fingerprint  TEXT NOT NULL,
    first_seen   TEXT,
    last_seen    TEXT,
    UNIQUE (license_id, fingerprint)
);

Логика проверки:

def check_or_register_device(lic, fingerprint):
    if not fingerprint:
        return  # старые сборки клиента работают без проверки
    existing = ...  # уже привязан?
    if existing:
        update_last_seen()
        return
    if devices_count(lic.id) >= lic.device_slots:
        raise HTTPException(403, "Лимит устройств исчерпан")
    insert_device(lic.id, fingerprint)

device_slots у платной лицензии — 3 (дом + офис + резерв), у триальной — 1. Цифры взяты с потолка по принципу «достаточно для нормальной работы, недостаточно для перепродажи».

Fingerprint считается так же, как в моей старой программе, но переписан кроссплатформенно — на Windows через vol c: + ProcessorNameString из реестра, на Mac через IOPlatformSerialNumber, на Linux через /etc/machine-id. Хэш SHA-256, отправляется с каждым запросом.

Триал без сложностей

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

В SaaS-модели всё проще: триал — это обычная лицензия с квотой 500 запросов и сроком 7 дней. Выдаёт её Telegram-бот по запросу пользователя:

@app.post("/admin/trial")
def admin_trial(body: TrialRequest):
    existing = find_trial_by_tg_user(body.tg_user_id)
    if existing:
        return existing  # уже выдавали — возвращаем тот же
    return create_trial(body.tg_user_id, quota=500, days=7)

Защита от мультиаккаунтов — по tg_user_id. Простая, обходимая (вторым Telegram-аккаунтом), но достаточная: один триал = 10 ₽ моих денег максимум, реальная накрутка нерентабельна. Когда станет рентабельной — добавлю проверку телефона или СМС.

Авто-обновления без головной боли

Программа при запуске тянет с моего сервера один JSON:

{
  "latest_version": "5.0",
  "download_url": "https://api.example.com/download/WordstatDeepDive.exe",
  "min_version": "5.0"
}

Если версия в JSON выше — в статус-баре появляется ссылка «Доступна новая версия 5.1». Клик ведёт на скачивание прямо с моего сервера.

Главное удобство — этот version.json лежит файлом на диске сервера, и эндпоинт /version читает его при каждом запросе. Чтобы выпустить апдейт, мне нужно:

  1. Собрать .exe локально (PyInstaller).

  2. scp нового файла в папку downloads/ на сервере.

  3. nano version.json, поменять latest_version, сохранить. Всё. Никакого CI/CD, никакого перезапуска сервера, никаких пуш-уведомлений. Через секунду каждый клиент при запуске программы увидит апдейт.

Мульти-валютный учёт

Платят мне из России, Беларуси и реже — из других стран. На счёт прилетает то рубли, то белорусские рубли, то доллары. Чтобы видеть реальную маржу, нужен общий знаменатель — я выбрала RUB.

Сервер раз в сутки тянет курсы с НБ РБ (https://www.nbrb.by/api/exrates/rates?periodicity=0), кэширует в памяти. Когда я выпускаю лицензию через админку, указываю сумму и валюту платежа — сервер записывает обе величины: оригинал и эквивалент в RUB по курсу на момент платежа.

revenue_rub = body.revenue * fx_rates_to_rub[body.revenue_currency]

Курс «замораживается» в момент платежа — это правильно с точки зрения учёта, потому что иначе после колебаний курса задним числом маржа по уже завершённым сделкам начала бы плавать.

Маленькая засада: НБ РБ периодически отвечает медленно (ReadTimeout). Сделала три попытки с увеличением таймаута до 30 секунд и кнопкой «Обновить курсы» в админке для ручного триггера.

Лендинг с динамическим счётчиком акции

Запуск нужно было как-то «продать» аудитории. Сделала акцию: −20% для первых 30 покупателей до даты X. Самое скучное в таких акциях — это ручной счётчик «осталось 17 мест!», который никто не обновляет.

У меня источник истины — сервер. Эндпоинт /promo считает количество выданных EARLY-лицензий в реальном времени:

@app.get("/promo")
def public_promo():
    used = count_licenses(note_contains="EARLY")
    return {
        "active": used < MAX_SLOTS and today < DEADLINE,
        "slots_remaining": MAX_SLOTS - used,
        "deadline": DEADLINE,
        "discount": 0.20,
    }

Лендинг при загрузке делает один fetch на этот эндпоинт. Если акция активна — рисует баннер сверху и зачёркивает старые цены, иначе ничего не делает. Никакой ручной правки HTML, никаких хардкоженных «осталось 23 места».

Когда акция кончится — баннер сам исчезнет, цены вернутся, в боте кнопки тарифов перестанут показывать скидку. CORS-заголовки разрешают лендингу на одном поддомене дёргать API на другом.

Грабли

Перечислю те, на которые потратила больше всего времени:

  1. CRLF в .env-файлах. На Windows я редактирую конфиги Notepad’ом или VS Code, на Linux они улетают через scp. Каждый раз в значениях прилипает \r, который не виден глазами, но ломает всё. Например, токен бота с \r на конце превращается в невалидный URL, и httpx падает на Invalid non-printable ASCII character in URL. Лечится одной командой:

   sed -i 's/\r$//' /home/user/bot/.env
  1. Telegram-conflict при рассылке. Запустила скрипт массовой рассылки на 80 человек одновременно с работающим ботом (тот же BOT_TOKEN). Бот «сдох» — перестал отвечать на /start. Причина: Telegram даёт обновления только одному getter’у, и если параллельно стучатся два процесса с одним токеном, второй получает 409 Conflict, а первый — пустые ответы. Урок: останавливать бот на время массовой рассылки.

  2. PyInstaller и антивирусы. .exe, собранный с --onefile, при запуске распаковывает Qt-DLL во временную папку. Windows Defender часто блокирует это «на лету», и пользователь видит Failed to extract MSVCP140.dll: decompression resulted in return code -3. Чинится двумя способами: добавить папку проекта в исключения антивируса (для разработки) и подписать .exe code-signing сертификатом (для пользователей). Сертификат стоит от $225/год + требует физический USB-токен с международной доставкой. Пока живу с дисклеймером «если антивирус ругается — нажмите Подробнее → Выполнить».

  3. Многоразовый .ico. Иконка в .exe подхватывалась, но в окне Qt — не показывалась. Оказалось, в .ico был только размер 256×256, а Windows для системного трея и заголовка окна берёт 16×16 и 32×32. Пересобрала через Pillow:

   img.save('app_icon.ico', format='ICO',
            sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])
  1. Caddy и права на домашний каталог. Caddy раздаёт статику лендинга из /home/user/landing/. По умолчанию домашняя папка имеет права 750 — Caddy не может в неё войти и отвечает 403. Лечится:

   chmod o+rx /home/user
   chmod -R o+rX /home/user/landing

(с большой X — только директориям, чтобы не сделать каждый .html исполняемым)

Юнит-экономика, прозрачно

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

Тариф

Запросов

Срок

Цена

За 1000 запросов

Start

10 000

90 дн

700 ₽

70 ₽

Pro

30 000

90 дн

1 800 ₽

60 ₽

Max

100 000

180 дн

5 000 ₽

50 ₽

То есть я продаю один запрос за 5–7 копеек, при том что у Яндекса он стоит 2 копейки. Наценка ~2.5–3.5×. Закономерный вопрос: «не офигела ли?». Разберу честно.

Цена 0,02 ₽ — это только прямая себестоимость API. На один проданный запрос реально приходится:

  • 0,02 ₽ — Яндекс,

  • ~0,001 ₽ — доля VPS (€101/год при ~50 активных клиентах в среднем),

  • 10–15% от выручки — налог НПД,

  • остаток — на поддержку, разработку, отток, риск триалов и всё остальное. После налога и инфраструктуры чистая маржа выходит 40–50%. Это нормально, а для нишевого b2b SaaS — даже немного скромно. Для сравнения: типичный SaaS-маркетплейс берёт комиссию 20–30% сверху, классические «коробочные» SEO-инструменты продают подписку с маржой 70–80%, а облачные провайдеры вроде AWS перепродают железо с наценкой 5–20× относительно прямой себестоимости.

Цены устроены так, чтобы цена за 1000 запросов снижалась с каждым следующим уровнем (70 → 60 → 50 ₽). Это базовое правило ценообразования, которое легко нарушить: первый раз я случайно сделала так, что Pro стоил дороже в пересчёте на запрос, чем Start, — пришлось переделать перед запуском.

Самое неочевидное — это стоимость триала. Я выдаю 500 запросов на 7 дней бесплатно. То есть на один триал я трачу до 10 ₽ моих живых денег, и если из 100 триалов купит хотя бы 5 — экономика работает (1 проданный Pro = 1800 ₽ перекрывает 100+ триалов). Если конверсия упадёт ниже 1% — буду уменьшать триал или ставить пороги.

Никаких автоплатежей, никакой ЮKassa, никакого Stripe. Клиент в Telegram-боте выбирает тариф → получает реквизиты (карта или счёт) → платит → присылает чек в тот же бот → я нажимаю одну команду /give pro @nickname 1800 → бот сам отправляет клиенту лицензионный ключ. Для текущего потока (единицы оплат в день) это удобнее, чем городить полноценный платёжный шлюз.

Что в итоге

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

Самое неожиданное — насколько проще оказалась поддержка после перехода на SaaS:

  • продлить лицензию — UPDATE в базе, секунда;

  • сбросить устройства клиенту, который переехал на новый комп, — кнопка в админке;

  • понять, почему у клиента что-то не работает, — посмотреть его последние запросы в журнале;

  • выпустить обновление программы — два scp и nano. В оффлайн-модели каждое из этих действий стоило час переписки и сборки персонального файла лицензии.

Что осталось «на потом»

Чтобы не выглядело так, что у меня всё идеально:

  • Code-signing сертификат — пока живу с дисклеймером про антивирус.

  • Иконка окна в трее — не подцепилась после последней сборки, баг в списке.

  • Версии под Mac и Linux — PyInstaller не делает кросс-сборку, нужно отдельные машины. Подожду, пока попросят клиенты.

  • Автоматизация платежей — пока ручной режим, при потоке 10+ оплат в день буду подключать ЮKassa или прямые вебхуки.

  • Защита от накрутки триалов — пока только по tg_user_id. Когда увижу попытки массовой регистрации фейковых TG, добавлю дневной потолок на выдачу триалов и месячный потолок расходов.


Если интересно обсудить конкретные решения, посмотреть куски кода или поделиться своим опытом перехода с десктопа на SaaS — пишите в комменты, отвечу с радостью.

Комментарии (5)


  1. Roman_Cherkasov
    30.06.2026 10:09

    А что произошло с пользователями которые купили пожизненную лицензию?