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-user

вход в Steam как клиент, сессии, refresh-токены

steamcommunity

куки и сессия steamcommunity.com, мобильные подтверждения

steam-tradeoffer-manager

создание, отслеживание и подтверждение трейд-офферов

steam-totp

коды Steam Guard и ключи подтверждения из shared_secret

globaloffensive

коннект к 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

ссылка на оригинал статьи https://habr.com/ru/articles/1082722/