Steam глазами разработчика: OpenID из 2007-го, звёздочка в имени ножа и цена строкой в чужой локали

Я писал сайт с кейсами CS2 — открытие с проверяемым роллом, апгрейд, контракты, инвентарь, вывод предметов торговыми ботами. Про математику, RTP и то, почему «provably fair» не означает «выгодно», у меня отдельная статья. Она самодостаточна, и читать их можно в любом порядке.
Эта — про слой между вашим кодом и Steam. Он сожрал больше времени, чем вся игровая логика вместе взятая, и нигде не собран в одном месте: каждый пункт ниже я выяснял отдельно и обычно методом «почему не работает».
Если вы когда-нибудь думали прикрутить к своему проекту вход через Steam, цены из маркета или обмен предметами — дальше примерно всё, на что вы наступите.
Репозиторий, где это всё работает: github.com/ialakey/caseforge, MIT.

Каждая картинка и каждая цена здесь приехали из Steam-маркета. Дальше про то, во что это обошлось.
Выбор языка мне не принадлежал
Я начинал с Go. Логика была простая: вебсокеты, нагрузка, всё такое. Пару дней спустя выяснилось, что решать тут вообще не мне.
Обмен предметами в Steam не ходит через официальный Web API. Web API умеет читать: инвентарь, профиль, историю. Создать трейд-оффер, подтвердить его мобильным аутентификатором, отследить, что с ним стало, — это внутренние эндпоинты steamcommunity.com. Работа с ними означает эмуляцию клиента Steam со всеми протоколами, которые Valve периодически меняет без предупреждения.
Живой набор библиотек для этого есть только под Node, у DoctorMcKay.
Библиотека | Что делает |
|---|---|
| вход в Steam как клиент, сессии, refresh-токены |
| куки и сессия |
| создание, отслеживание и подтверждение трейд-офферов |
| коды Steam Guard и ключи подтверждения из |
| коннект к game coordinator CS2: float, паттерн, стикеры |
Питоновские аналоги (steam, steampy) существуют, но заметно отстают: после очередного изменения на стороне Valve их чинят позже, с мобильными подтверждениями там хуже. Для проекта, где ферма ботов работает кассой, неделя отставания означает неделю без выводов.
Значит, торговый слой в любом случае будет на Node. А раз так, писать остальной бэкенд на втором языке — это две модели данных, два набора типов и мост между ними ради нескольких процентов RPS, которые мне на домашнем проекте не сдались.
Так весь стек и оказался на TypeScript: Next.js на фронте, NestJS на Fastify в API, отдельный Node-воркер под ферму ботов. Решение принял не я, а Valve — просто не сообщила об этом напрямую.
Проблемы, собранные в одном месте
Я знал, что со Steam будет неприятно. Я не знал, что настолько.
OAuth у Steam нет. Есть OpenID 2.0 образца 2007 года. Редирект на steamcommunity.com/openid/login, возврат с кучей параметров и обязательная серверная проверка через check_authentication. Без неё параметры подделываются тривиально: подставил чужой SteamID64 в query — вошёл под чужим аккаунтом. Это вообще вся безопасность входа, а не формальность.
OpenID возвращает только SteamID64. Ни ника, ни аватара в ответе нет, всё остальное добирается отдельными запросами.
Источников профиля два, и второй не костыль. GetPlayerSummaries требует STEAM_API_KEY (100k запросов в сутки на ключ), а /profiles/<id>?xml=1 не требует ничего и отдаёт то же самое. Web API у меня основной, XML запасной. Ключ протух или Web API прилёг: пользователь всё равно видит свой ник и аватар, а не user_123456.
В XML профиля лежат чужие аватары. Точнее, внутри есть вложенные списки друзей и групп, и у каждого свои <steamID> и <avatarFull>. Наивное «взять первое совпадение регуляркой» иногда подставляет аватар случайного друга. Отлаживать это весело: воспроизводится не у всех, зависит от того, открыт ли список друзей. Кончилось тем, что я режу строку по первому вхождению <friends>, <groups>, <friendslist> или <mostPlayedGames> и парсю только голову.
Email Steam не отдаёт никогда. Ни одним из путей. Его не будет, пока пользователь сам не введёт, и все сценарии восстановления доступа приходится строить вокруг Steam-аккаунта.
Поиск по маркету не ест market_hash_name. Символ | и скобки экстерьера дают ноль результатов для предметов, которые прямо сейчас продаются сотнями лотов. Имя нужно раздеть до ключевых слов.
И отдельно про звёздочку. У ножей и перчаток в market_hash_name есть префикс ★. Karambit | Doppler (Factory New) для Steam не существует, ★ Karambit | Doppler (Factory New) существует. Без звезды не резолвится ни цена, ни картинка. Но поиск звезду не переваривает, и её надо убирать. То есть одно и то же имя для двух соседних эндпоинтов готовится двумя противоположными способами, и я потратил приличное время, прежде чем в это поверил.
Эндпоинта два, и свойства у них разные. market/search/render отдаёт имя, картинку, редкость и число лотов — и всегда в долларах, параметр currency он молча игнорирует. market/priceoverview отдаёт только цену, зато уважает валюту и требует точный market_hash_name. Отсюда разделение: поиск для выбора предмета и метаданных, цена в валюте проекта всегда из priceoverview.
Цена приходит строкой, отформатированной по локали. "$86.96", "3225,06 руб.", "1.234,56€", "₩ 12,500". Разделитель то точка, то запятая, у части валют дробной части нет вообще, а в рублёвом варианте к числу липнет точка из «руб.». Парсер, написанный на глаз, рано или поздно превратит 1.234,56€ в рубль двадцать три, и предмет уедет в кейс почти бесплатно. У меня десятичный разделитель определяется позиционно: это последний разделитель, за которым ровно две цифры и больше ничего.
const digitsAndSeparators = text.replace(/[^\d.,]/g, '').replace(/^[.,]+|[.,]+$/g, '');
if (!/\d/.test(digitsAndSeparators)) return null;
const decimalMatch = /^(.*)([.,])(\d{2})$/.exec(digitsAndSeparators);
Функция возвращает null, а не ноль, когда числа в строке нет. Ноль сделал бы предмет бесплатным, а это из тех багов, которые не замечают до первого очень странного дропа.

Всё на этой витрине приехало из Steam и по разным маршрутам: имя со звёздочкой и картинка — из search/render, цена в рублях — из priceoverview, редкость — оттуда же, откуда имя. Одно имя предмета готовится для этих эндпоинтов двумя разными способами.
Берите медиану, а не минимум. lowest_price скачет от одиночных сливов, и кейс, посчитанный по нему, стабильно недооценивает собственные предметы.
Лимит — примерно 20 запросов в минуту. Дальше 429 и бан на несколько минут. У меня запросы сериализованы с интервалом 3,5 секунды, поиск кешируется на час, цены на полчаса, а 429 усыпляет сервис на пять минут. Негативные ответы кешируются тоже: предмет без лотов иначе ходит в Steam при каждой синхронизации и выедает лимит за остальных.
Из-за последнего пункта команда наполнения каталога честно предупреждает, что двадцать тематических кейсов она будет собирать минут десять. Зато состав не захардкожен именами: предметы подбираются поиском по маркету, и у каждого гарантированно есть картинка и живая цена. Та же джоба заодно дотягивает недостающие картинки, потому что пустая витрина выглядит как сломанная, а кейс без собственной картинки одалживает изображение самого дорогого предмета — он его и продаёт.
Валюта отображения и валюта расчёта — разные вещи
Вернусь к пункту про два эндпоинта маркета: из него вытекает следствие, на котором легко погореть, и я вынес его отдельно.
Раз один эндпоинт маркета отдаёт только доллары, а второй — ту валюту, которую попросили, то валют в проекте с самого начала минимум две. И главный вопрос не «как конвертировать», а «какая из них попадает в базу».
Ответ: никакая из тех, что видит пользователь. Считается всё в рублях: балансы, цены предметов и кейсов лежат целыми минорными единицами, и ни одна запись в журнале транзакций никогда не содержит сконвертированную сумму. Переключатель ₽/$ в шапке влияет только на рендер — долларовая цифра это то же самое число, поделённое на курс в момент отрисовки.
Стоит один раз сохранить в базу сконвертированное значение, и у вас появляются две истины про одну сумму. Дальше курс двигается, и сайт начинает продавать предметы ниже себестоимости, причём не сразу и совершенно неочевидно.
Курс берётся из дневного фида ЦБ РФ (без ключа и квот), обновляется раз в шесть часов, лежит в Redis. Фид недоступен — отдаётся предыдущий курс, устаревший курс всё же лучше сломанного прайс-листа.
Ферма ботов
Один бот — это отдельный Steam-аккаунт с включённым мобильным аутентификатором, у которого есть shared_secret и identity_secret из maFile. Без них офферы не подтвердить автоматически. В базе секреты не лежат открытым текстом: AES-256-GCM под ключом из окружения. Для прода сюда просится внешний секрет-стор, но в проекте его нет — ключ так и берётся из переменной окружения, и я предпочитаю это назвать, а не сделать вид, что там что-то серьёзнее.
Ограничения, вокруг которых построена вся логика вывода:
инвентарь Steam — 1000 слотов, поэтому ботов нужно несколько, и заявку надо отправлять тому, у кого предмет реально лежит;
трейд-холд до 15 дней, если у получателя нет мобильного аутентификатора или он включён недавно. Проверять это надо до отправки оффера, иначе предмет зависает в эскроу, а пользователь идёт в поддержку;
бот, недавно сменивший пароль или устройство, уходит в холд сам;
Valve лимитирует создание офферов, так что очередь с троттлингом обязательна.
Цепочка:
заявка → валидация (trade URL, холд, лимиты, антифрод)
→ очередь withdrawals в BullMQ
→ выбор бота, у которого есть предмет и свободные слоты
→ создание трейд-оффера
→ автоподтверждение через identity_secret
→ опрос статуса
→ accepted / declined / expired → обновление InventoryItem
Вся цепочка идемпотентна по withdrawalId. Ретрай джобы без идемпотентности — это выданный дважды предмет.
Ещё мелочь: trade URL проверяется на принадлежность аккаунту. partner в ссылке — это младшие 32 бита SteamID64, и чужая ссылка отбрасывается до того, как по ней уедет предмет.
Оговорка, чтобы никто не удивлялся, запустив проект. Всё описанное выше в коде есть, но по умолчанию простаивает: ферму надо поднимать отдельно, с живыми Steam-аккаунтами и их секретами, а интерфейс пока ходит в заглушку. Раздавать в учебном репозитории готовый к бою торговый воркер мне показалось плохой идеей.
Что из этого стоит унести с собой
Вход через Steam — это не OAuth и никогда им не был. Считайте, что вы интегрируетесь с 2007 годом, и обязательно верифицируйте ответ на сервере: без check_authentication вход подделывается подстановкой чужого SteamID в query.
Ник и аватар придётся добирать отдельно, и запасной путь тут не роскошь: ключ Web API протухает, а ?xml=1 работает без ключа и квот. Только парсите его аккуратно, иначе подставите аватар случайного друга.
Маркет — это два разных эндпоинта с разными свойствами и разными требованиями к одному и тому же имени предмета. Звёздочку у ножа для одного надо добавить, для другого убрать. Цена приезжает строкой в локальном формате, и парсер «на глаз» рано или поздно превратит 1.234,56€ в рубль двадцать три.
И закладывайте лимиты в архитектуру с самого начала, а не когда прилетит первый 429. Двадцать запросов в минуту — это не «изредка подождать», это ограничение, вокруг которого строится вся синхронизация каталога: сериализация, интервалы, кеш на положительные ответы и обязательно на отрицательные.
А вывод предметов — отдельная система со своей очередью, идемпотентностью и учётом чужих ограничений: тысяча слотов в инвентаре бота, трейд-холды до 15 дней, лимиты Valve на создание офферов. «Просто отправить предмет» тут не бывает.
Про то, как во всём этом устроена математика — RTP, диапазоны тикетов, солверы под целевое матожидание и почему «provably fair» отвечает совсем не на тот вопрос, который задаёт игрок, — в первой статье.
Код целиком, архитектурный документ (на русском тоже) и запуск в три команды: github.com/ialakey/caseforge · MIT
cp .env.example .env # править ничего не нужно для локального запуска
pnpm setup # install + docker + build + миграции + сид
pnpm dev # api на :4000, web на :3000
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.