Технический пост-мортем: что было, что сломалось, и что в итоге собралось.
Контекст: умер 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 читает его при каждом запросе. Чтобы выпустить апдейт, мне нужно:
-
Собрать
.exeлокально (PyInstaller). -
scpнового файла в папкуdownloads/на сервере. -
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 на другом.
Грабли
Перечислю те, на которые потратила больше всего времени:
-
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
-
Telegram-conflict при рассылке. Запустила скрипт массовой рассылки на 80 человек одновременно с работающим ботом (тот же
BOT_TOKEN). Бот «сдох» — перестал отвечать на/start. Причина: Telegram даёт обновления только одному getter’у, и если параллельно стучатся два процесса с одним токеном, второй получает 409 Conflict, а первый — пустые ответы. Урок: останавливать бот на время массовой рассылки. -
PyInstaller и антивирусы.
.exe, собранный с--onefile, при запуске распаковывает Qt-DLL во временную папку. Windows Defender часто блокирует это «на лету», и пользователь видитFailed to extract MSVCP140.dll: decompression resulted in return code -3. Чинится двумя способами: добавить папку проекта в исключения антивируса (для разработки) и подписать.execode-signing сертификатом (для пользователей). Сертификат стоит от $225/год + требует физический USB-токен с международной доставкой. Пока живу с дисклеймером «если антивирус ругается — нажмите Подробнее → Выполнить». -
Многоразовый
.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)])
-
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 — пишите в комменты, отвечу с радостью.
ссылка на оригинал статьи https://habr.com/ru/articles/1053848/