The Jerusalem PostShayetet 13 Navy SEALs conduct counterterror raids near Hebron, West Bank overnightPunchMy chess ambitions got bigger after World Rapid amateur title — OmoruyiRTP DesportoLiga das Nações: Jesus apresenta primeiras escolhas na sexta-feiraDaily MaverickWHAT’S COOKING: Chicken and bacon pasta with peasBollywood HungamaCinépolis to distribute Anshuman Jha and Raghubir Yadav’s Om Ka Hari in theatersוואלהחפץ חשוד אותר סמוך לבית בחדרה, הרקע ככל הנראה פליליInquirer EntertainmentEd Sheeran’s tour opening acts drop out in solidarity with MacklemoreInquirerMarcos hands off on Duterte impeachment vote threshold – PalaceUOLArábia Saudita acusa houthis de ataque contra Meca20 MinutenIst der Verkehr durch Hormus wirklich wieder normal?Owngoal NigeriaBetway or Stake: what should Nigerian football bettors actually compare?Wirtualna PolskaHuti zestrzelili saudyjski myśliwiec F-15. Jest nagranie
The Daily Newsstand · Free, Always
Wednesday, September 16, 2026

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

Translate

Я писал сайт с кейсами 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
View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.