Почему выдает ошибку в запросе, расшифрованном из cUrl?
Когда в стиме добавляешь в корзину товар, то появляется запрос. Я скопировал его cUrl bash и в конверторе получил c# код для запроса. Но когда я попытался его выполнить, то выдало ошибку:
System.Net.Http.HttpRequestException: "Error while copying content to a stream."
HttpIOException: The response ended prematurely.
сам код выглядит так:
HttpClient client = new HttpClient();
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1?access_token=ACCESS_TOKEN");
request.Headers.Add("accept", "*/*");
request.Headers.Add("accept-language", "ru,en;q=0.9,en-GB;q=0.8,en-US;q=0.7");
request.Headers.Add("origin", "https://store.steampowered.com");
request.Headers.Add("priority", "u=1, i");
request.Headers.Add("referer", "https://store.steampowered.com/");
request.Headers.Add("sec-ch-ua", "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"");
request.Headers.Add("sec-ch-ua-mobile", "?0");
request.Headers.Add("sec-ch-ua-platform", "\"Windows\"");
request.Headers.Add("sec-fetch-dest", "empty");
request.Headers.Add("sec-fetch-mode", "cors");
request.Headers.Add("sec-fetch-site", "same-site");
request.Headers.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0");
MultipartFormDataContent content = new MultipartFormDataContent();
content.Add(new StringContent("CgJLWhIECM/wJxo8ChZzdG9yZS5zdGVhbXBvd2VyZWQuY29tEhBzdG9yZS1uYXZpZ2F0aW9uGgAiACoAMAA6AEgAUgBYAGAA"), "input_protobuf_encoded");
request.Content = content;
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=----WebKitFormBoundarynRWoW4pwISqiGxW5");
HttpResponseMessage response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
Почему может появляться такая ошибка?
Ответы (1 шт):
Решение основной проблемы (анализ кода)
Основная ваша проблема заключается в том, что вы, без опыта, без каких либо знаний о том, как работают запросы, побежали искать "легкие пути" (генераторы/конверторы), где бездушные машины написали вам полнейшую чушь. Я вам не просто так писал под предыдущим вопросом "не используйте это!", но нет, взяли за основу.
Давайте разберемся почему этот код плохой:
Основная проблема в том, что конвертор вам написал следующее:
MultipartFormDataContent content = new MultipartFormDataContent(); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=----WebKitFormBoundarynRWoW4pwISqiGxW5");То есть, задается заголовок
ContentTypeс неким статичным значением разделителя данных, ок... Но самой форме (MultipartFormDataContent) этот разделитель был задан? Нет. В итоге вы отправляете серверу такое:Content-Type: multipart/form-data; boundary=----WebKitFormBoundarynRWoW4pwISqiGxW5 --4140fdf6-3bc4-412b-9e1a-db1a3118c120 Content-Type: text/plain; charset=utf-8 Content-Disposition: form-data; name=input_protobuf_encoded CgJLWhIECM/wJxo8ChZzdG9yZS5zdGVhbXBvd2VyZWQuY29tEhBzdG9yZS1uYXZpZ2F0aW9uGgAiACoAMAA6AEgAUgBYAGAA --4140fdf6-3bc4-412b-9e1a-db1a3118c120--То есть, говорите "Ей, сервер! Вот тебе данные, которые разбиты по кускам. Разбей их по разделителю
----WebKitFormBoundarynRWoW4pwISqiGxW5". Сервер смотрит, пытается найти начало разделителя в данных, а его нет. Вот он и обрывает соединения, отдавая вам ошибку400.Как исправить: Вынесите разделитель в отдельную переменную, а затем установите его как
ContentType, так иMultipartFormDataContent. Но помните, что разделитель должен быть уникальным, не встречающимся в данных. Так что, статичный разделитель - не лучший вариант.Все заголовки в вашем коде бесполезны. Вы скопировали целиком запрос, не подувал что там и зачем. Затем этот запрос бездушной машиной был преобразован в C# код с тонной лишнего. Давайте вот пробежимся по всем заголовкам:
acceptиaccept-language- Зачастую их ставит сам браузер/клиент, а для ручной работы с API они бесполезны.sec-- Это заголовки, которые ставит ваш браузер, в них информация о вашей системе, браузере, и т.д. Эти данные для работы с API запросами вообще не нужны.priority- Относительно новый заголовок, который работает по большей части в HTTP/3. Его суть в том, чтобы сказать серверу, какой запрос нужно обработать первым. Это полезно, когда вы отсылаете сразу десяток запросов на сервер и у вас есть некая очередность обработки данных. В вашем случае, запрос отправки товара в корзину - это один единственный запрос на сервер, который будет выполнен, ну не знаю, раз в минуту (выбрать товар, кликнуть добавление). А значит и заголовок этот вам бесполезен.user-agent,origin,referer- Вот эти заголовки да, довольно часто нужные. НО... Если внимательно изучите работу сервера Steam, то увидите, что они бесполезны.
В итоге 100% всех заголовков из вашего кода можно смело убирать.
В общем, у вас сейчас плохой, грязный, нерабочий код чисто из-за того, что вы его создали не сами, а при помощи машины, которая не умеет думать, не умеет анализировать. Я вам еще в первом вопросе сказал "Не используйте это, плохо будет!" и вот вы обожглись буквально через день. Так что, советую прекратить использовать подобные генераторы кода и начать писать/анализировать запросы вручную. Сэкономите уйму времени, поверьте.
Ну а для решения проблемы, которая в вопросе, вам достаточно просто исправить первый пункт. Но Steam не выполнит то, что вы от него просите, ибо там есть ряд тонкостей, которые надо изучать, разбирать, понимать. Собственно, давайте этим и займемся.
Анализ трафика и чистка запроса
Для того, чтобы написать грамотный запрос на C#, нужно для начала проанализировать запросы, которые отправляет браузер на сервер. Как это сделать?
- Качаем Fiddler - это программа, которая поможет отловить не только браузерные запросы, но и запросы вашего приложения.
- В программе включаем отлов HTTPS трафика.
- Идем на сайт Steam и добавляем игру в корзину.
- В Fiddler увидите запрос, где на вкладке
Inspectorsбудет все, что отправил клиент и все, что ответил сервер. - Теперь качаем Postman - программа, которая позволяет "играться" с запросами, отправляя их на сервер.
- В Postman заполняем все поля, копируя их из Fiddler
- Отправляем, убеждаемся в том, что запрос рабочий (200 код и на сайте появился товар в корзине)
- Теперь отключаем все лишние заголовки/данные/cookie, чтобы запрос был максимально легким, чистым, и самое главное, рабочим!
- Поздравляю, вы сделали свой чистый запрос, который можно дальше переносить на C#
Получится у вас в итоге должно что-то такое:
POST https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1?access_token=eyAidHlwIZVt3wXJCN039AQ HTTP/1.1
Host: api.steampowered.com
Content-Type: multipart/form-data; boundary=--------------------------051291036859777264404944
Content-Length: 305
----------------------------051291036859777264404944
Content-Disposition: form-data; name="input_protobuf_encoded"
CgJSVRIECL+UARpSChZzdG9yZS5zdGVhbXBvd2VyZWQuY29tEgdkZWZhdWx0GgdkZWZhdWx0IgAqFm1haW4tY2x1c3Rlci10b3BzZWxsZXIwAToCUlVIAFIAWABgAA==
----------------------------051291036859777264404944--
а ответ будет примерно такой:
HTTP/1.1 200 OK
Server: nginx
Content-Type: application/octet-stream
Content-Length: 78
X-eresult: 1
Expires: Sat, 12 Jul 2025 15:48:35 GMT
Date: Sat, 12 Jul 2025 15:48:35 GMT
Connection: keep-alive
ލF
. ލ @PӇ Z
312 рубj
312 руб
Что мы тут видим?
- В запросе нет ни единого заголовка (они не нужны)
- Значение
boundaryуContent-Typeимеет некое случайное значение, которое сгенерировал Postman. Заметьте, оно совершенно другое, чем то, что шлет браузер. Все дело в том, что стандарта в этом плане нет, там может быть любое значение, но только, оно не должно встречаться в самих данных, это важно! - В ответ нам сервер отдал
application/octet-stream- это по сути, набор байт (бинарные данные), которые надо правильно расшифровать.
Эмуляция запроса на C#
Имея чистый, рабочий запрос, а также нормальный ответ, мы можем приступить к написанию кода на C#. Вы этот код написали, давайте исправим проблему с boundary и отправим, посмотрим как себя ведет код и как виден запрос в Fiddler.
Запрос выглядит так:
POST https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1?access_token=eyAidHlwIZVt3wXJCN039AQ HTTP/1.1
Host: api.steampowered.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarynRWoW4pwISqiGxW5
Content-Length: 320
------WebKitFormBoundarynRWoW4pwISqiGxW5
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name=input_protobuf_encoded
CgJSVRIECL+UARpSChZzdG9yZS5zdGVhbXBvd2VyZWQuY29tEgdkZWZhdWx0GgdkZWZhdWx0IgAqFm1haW4tY2x1c3Rlci10b3BzZWxsZXIwAToCUlVIAFIAWABgAA==
------WebKitFormBoundarynRWoW4pwISqiGxW5--
А ответ такой:
HTTP/1.1 200 OK
Server: nginx
Content-Type: application/json; charset=UTF-8
Content-Length: 15
X-eresult: 8
Expires: Sat, 12 Jul 2025 15:59:40 GMT
Date: Sat, 12 Jul 2025 15:59:40 GMT
Connection: keep-alive
{"response":{}}
С первого взгляда все идентично, да? Но почему тогда в ответ на сервер дал application/json и пустой JSON?
Вы можете сами изучить и найти отличия, но чтобы долго не томить, отвечу сразу. Обратите внимание на значение name в теле запроса. В рабочем оно name="input_protobuf_encoded", а через C# отправляется name=input_protobuf_encoded. Да, вот такая тонкость и приводит к неверному результату. Поэтому, еще раз, не полагайтесь на генераторы кода, изучайте каждый сайт самостоятельно, находите к каждому сайту свой подход. В данном случае Steam очень капризен в плане кавычек, если их нет, то он не считает эти данные валидными.
Как решить эту проблему?
Достаточно установить верный ContentDisposition контенту (у нас это StringContent).
То есть, строку
content.Add(new StringContent("...", "input_protobuf_encoded"));
переписываем на
content.Add(new StringContent("...", "\"input_protobuf_encoded\""));
Вот такая вот тонкость, да...
После этого запрос будет отправляться успешно.
Кстати, еще один "бзик" Steam по поводу кавычек и из-за чего нужно вручную устанавливать boundary:
Удаляем ручную установку типа контента и смотрим на запрос:
POST https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1?access_token=eyAidHlwIZVt3wXJCN039AQ HTTP/1.1
Host: api.steampowered.com
Content-Type: multipart/form-data; boundary="14ca31f1-0e81-4dc7-a50e-9aecbf45b20d"
Content-Length: 318
--14ca31f1-0e81-4dc7-a50e-9aecbf45b20d
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name="input_protobuf_encoded"
CgJSVRIECL+UARpSChZzdG9yZS5zdGVhbXBvd2VyZWQuY29tEgdkZWZhdWx0GgdkZWZhdWx0IgAqFm1haW4tY2x1c3Rlci10b3BzZWxsZXIwAToCUlVIAFIAWABgAA==
--14ca31f1-0e81-4dc7-a50e-9aecbf45b20d--
Опять, вроде все хорошо, да? Да вот нет... Сервер нас вежливо посылает
HTTP/1.1 400 Bad Request
Server: nginx
Content-Type: text/html; charset=UTF-8
Content-Length: 96
Expires: Sat, 12 Jul 2025 16:12:53 GMT
Cache-Control: max-age=0, no-cache, no-store
Pragma: no-cache
Date: Sat, 12 Jul 2025 16:12:53 GMT
Connection: close
Почему? Внимательно смотрим на boundary, кавычки, да...
В итоге, для успешной отправки запроса на сервер Steam требует:
- Верно установленный
Content-Type, в которомboundaryбудет без кавычек. nameу контента должен обязательно быть с кавычками.
Кодирование/Декодирование контента
Вы ведь не хотите постоянно отсылать непонятные строчки по типу UlVIAFIAWABgAA==, верно? Вот для того, чтобы было удобно и понятно, надо понять что это. На помощь нам приходит название данных, которые отсылаются, где прямо написано input_protobuf_encoded. То есть, это некие кодированные данные Protobuf (весьма популярного типа передаваемых данных).
Разобравшись с данными, мы можем попытаться раскодировать эту строку, пока online.
В интернете много разных сайтов, которые позволяют это сделать, я возьму Protobufpal. Открываем, вставляем в самый низ строку значение input_protobuf_encoded, жмем декодировать и смотрим что там. Вот раскодированные данные вашей строки из вопроса:
{
"string_1": "KZ",
"subMesssage_2": {
"int_1": 653391
},
"subMesssage_3": {
"string_1": "store.steampowered.com",
"string_2": "store-navigation",
"subMesssage_3": {},
"subMesssage_4": {},
"subMesssage_5": {},
"int_6": 0,
"subMesssage_7": {},
"int_9": 0,
"subMesssage_10": {},
"int_11": 0,
"int_12": 0
}
}
Смотрите, уже что-то внятное. Мы видим страну магазина, некий id, а также некие адреса. Но как-то это выглядит, странно... Будто нету чего-то важного. Ну а чтобы понять чего нет, нужно изучить что такое Protobuf и как он работает. После небольшого изучения вы поймете, что для кодирования/декодирования Protobuf запросов нужны специальные файлы .proto, которые содержат в себе всю важную информацию о данном запросе. Тут многое зависит от сервера, ибо эти файлы, по сути, приватны. Но для стима умельцы их воссоздали и вы можете без проблем найти из в интернете, написав в поисковике steam proto protobuf. Одним из таких мест является репозиторий SteamDatabase/Protobufs.
И так, место нашли, неужели надо качать все? Нет. Методы в .proto совпадают по именам с теми, что и в API. Мы сейчас отправляем запрос на IAccountCartService/AddItemsToCart, а значит мы можем поискать AddItemsToCart метод в репозитории. И да, в файле webui/service_accountcart.proto есть нужные нам методы с названиями CAccountCart_AddItemsToCart_Request, CAccountCart_AddItemsToCart_Request_ItemToAdd, CAccountCart_AddItemsToCart_Response.
Давайте попробуем скопировать код этого файла и вставить его на сайт для декодирования. Копируем, вставляем в левый столбец, по середине выбираем CAccountCart_AddItemsToCart_Request (ибо мы декодируем запрос), жмем декодировать и... Ошибка "Failed to import "common_base.proto".". Почему? Все дело в том, что тут также, как и в C#, код наследуется друг от друга, где в самом верху файла указан путь до файла, из которого нужно импортировать код. В данном случае common_base.proto и common.proto. Жмем на плюсик (добавить новую вкладку) на сайте и импортируем туда эти файлы (или копируем вручную), после чего пробуем декодировать по новой. common_base.proto ссылается еще на google/protobuf/descriptor.proto, который можно взять в том-же репозитории, где и основные .proto файлы.
И вот наши декодированные данные:
{
"items": [
{
"packageid": 653391,
"bundleid": 0,
"gift_info": null,
"flags": null
}
],
"user_country": "KZ",
"navdata": {
"domain": "store.steampowered.com",
"controller": "store-navigation",
"method": "",
"submethod": "",
"feature": "",
"depth": 0,
"countrycode": "",
"webkey": 0,
"is_client": false,
"curator_data": {
"clanid": 0,
"listid": 0
},
"is_likely_bot": false,
"is_utm": false
}
}
Вот теперь понятно что и за что отвечает.
Используем Protobuf на C#
Для работы с этим типом данных нам нужно установить 2 NuGet пакета:
Google.Protobuf- Он нам поможет преобразовать C# класс в массив байт.Grpc.Tools- Основной пакет, который прочитает.protoфайлы и сгенерирует на их основе .cs файлы.
А теперь приступаем:
Создаем папку, в которой будут храниться все наши
.protoфайлы. Я назову ееProtos.В эту папку закидываем 4
.protoфайла, которые получили ранее.Далее открываем
.csprojфайл и прописываем там следующее:<ItemGroup> <Protobuf Include="Protos\**\*.proto" GrpcServices="None" AdditionalImportDirs="Protos" /> </ItemGroup>Так мы указываем в нашем проекте где именно находятся файлы
.proto. Если папку назвали иначе, то не забудьте переименовать ее и в этой строке.Теперь пересобираем проект. Он должен успешно собраться и сгенерировать все необходимые классы.
Теперь мы можем собирать нужные нам данные. Мы делаем запрос, а значит нам нужен класс CAccountCart_AddItemsToCart_Request. Создаем и заполняем его нужными данными. Получим что-то подобное:
var request = new CAccountCart_AddItemsToCart_Request
{
UserCountry = "RU"
};
request.Items.Add(new CAccountCart_AddItemsToCart_Request_ItemToAdd
{
Packageid = packageId
});
Обратите внимание, я не стал заполнять все данные, что мы видели на сайте, а лишь те, что необходимы (опять, нашел путем удаления лишнего). Steam'у важна именно страна (по которой он понимает валюту) и ID пакета, не игры (об этом ниже). Также тут может быть полезным указать ID DLC, или другого материала (изучайте уже сами), но для добавления именно игры, достаточно этого. Все остальные данные относятся к навигации стима (чтобы он мог перенаправить вас обратно на нужную страницу). Также обратите внимание на то, что Items - это список, в который вы можете добавить сразу несколько объектов (несколько игр), но в данном случае, обойдемся одной игрой.
Теперь нам надо это преобразовать в массив байт, а массив байт в Base64 строку (ту, что вы видите в запросе). Делается это так:
var protoBytes = request.ToByteArray();
var inputProtobufEncoded = Convert.ToBase64String(protoBytes);
Объединяем для удобства в один метод:
public string EncodeAddItemsRequest(uint packageId)
{
var request = new CAccountCart_AddItemsToCart_Request
{
UserCountry = "RU"
};
request.Items.Add(new CAccountCart_AddItemsToCart_Request_ItemToAdd
{
Packageid = packageId
});
var protoBytes = request.ToByteArray();
var inputProtobufEncoded = Convert.ToBase64String(protoBytes);
return inputProtobufEncoded;
}
Готово, теперь при вызове метода мы будем получать сразу готовую строку input_protobuf_encoded, и уже сейчас можете ее подставить в запрос и будет все работать.
Чтение ответа от сервера.
Как помните, в ответ сервер отдает application/octet-stream, а текстом все выглядит как некая непонятная чепуха. Как я уже писал, это массив байт, тоже Protobuf объект, который надо декодировать. Чтобы это сделать в вашем коде, надо:
- Поменять
.ReadAsStringAsync()на.ReadAsByteArrayAsync()(читаем байты). Либо, что еще лучше, на.ReadAsStreamAsync()- читаем поток, тем самым не грузим все данные в память. Но в данном случае данных мало, поэтому смысла не вижу. - Парсим в класс
CAccountCart_AddItemsToCart_Response(ответ) полученные байты. У скаченных классов.protoесть объектParser, который и помогает с этим. Достаточно вызватьCAccountCart_AddItemsToCart_Response.Parser.ParseFrom(responseContent).
В итоге код будет примерно таким:
var responseContent = await response.Content.ReadAsStreamAsync();
var result = CAccountCart_AddItemsToCart_Response.Parser.ParseFrom(responseContent);
На этом можно было бы и завершить ответ, но есть еще одна очень важная штука, а именно...
Получение PackageId
У каждого продукта стима есть так называемые "пакеты" - это именно то, что вам предлагают купить на странице магазина. Каждый пакет имеет свой уникальный ID. Вы можете на странице игры кликнуть по кнопке "В корзину" правой кнопкой мыши - "Инспектировать", откроется инструмент разработчика, где будет выделена кнопка. Там вы найдете что-то такое: id="btn_add_to_cart_172295", вот это и есть ID пакета. Если еще немного изучить HTML, то можно увидеть там такую строку <input type="hidden" name="subid" value="172295">. Собственно, вот вам первый способ получения ID пакета (парсинг HTML).
Но давайте пойдем иначе. У стима есть API, который дает всю нужную информацию об игре. Находится он по адресу:
https://store.steampowered.com/api/appdetails?appids=...
Как видите, он принимает уже ID конкретного приложения, а в ответ на простой GET запрос (даже без авторизации) он возвращает JSON с полным описанием игры, где будет
package_groups- Список групп доступных для покупки пакетов, в немdefault- это базовая игра.packages- это простой массив чисел, в котором по порядку (не уверен) идут все пакеты конкретной игры.
Вот давайте распарсим это все, и сделаем метод, который вернет нам ID пакета указанной игры для покупки. Будет что-то типа такого:
Классы
public class AppDetailsRoot
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("data")]
public AppDetailsData Data { get; set; } = new();
}
public class AppDetailsData
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("steam_appid")]
public uint SteamAppId { get; set; }
[JsonPropertyName("packages")]
public uint[] Packages { get; set; } = [];
[JsonPropertyName("package_groups")]
public PackageGroup[] PackageGroups { get; set; } = [];
}
public class PackageGroup
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("subs")]
public PackageSubs[] Subs { get; set; } = [];
}
public class PackageSubs
{
[JsonPropertyName("id")]
public uint Id { get; set; }
}
Сам метод
using System.Net.Http.Json;
public async Task<uint> GetGamePackageId(uint appId)
{
var response = await _httpClient.GetFromJsonAsync<Dictionary<uint, AppDetailsRoot>>($"https://store.steampowered.com/api/appdetails?appids={appId}");
if (response == null || !response.TryGetValue(appId, out var appDetails) || !appDetails.Success)
{
throw new Exception($"Failed to retrieve app details for appId {appId}");
}
if (appDetails.Data.Packages?.Length is not > 0)
{
throw new Exception($"No packages found for appId {appId}");
}
return appDetails.Data.Packages[0];
}
Классы можете дополнять, я лишь взял те данные, которые могут пригодиться.
P.S. Методом "тыка" нашел у этого API фильтрацию контента. Задается этот фильтр параметром filters в URL.
Например:
https://store.steampowered.com/api/appdetails?appids=570&filters=packages
вернет такой JSON (в нем находятся только данные пакетов):
{
"570": {
"success": true,
"data": {
"packages": [49307, 197846, 330209],
"package_groups": [
{
"name": "default",
"title": "Купить Dota 2",
"description": "",
"selection_text": "Выберите способ покупки",
"save_text": "",
"display_type": 0,
"is_recurring_subscription": "false",
"subs": [
{
"packageid": 197846,
"percent_savings_text": ": ",
"percent_savings": 0,
"option_text": "Dota 2 - Commercial License - Бесплатно",
"option_description": "",
"can_get_free_license": "0",
"is_free_license": true,
"price_in_cents_with_discount": 0
}
]
}
]
}
}
}
Это может пригодиться, если требуется только конкретнея информация и не хочется работать с большим набором данных.
Кстати, обратите внимание на данные. Брать первый id из packages не всегда лучший вариант, ибо игра может быть, допустим, бесплатной, а сам покупаемый пакет будет в середине/конце списка. Так что, советую переписать метод получения объекта на поиск нужной группы, с нужным name (обычно это default).
Финальный результат
Я переписал код из вопроса по своему, разделил все на методы, сделал удобно и красиво. В отдельный класс выносить не стал (нагрузит ответ), но вам советую.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
public class AppDetailsRoot
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("data")]
public AppDetailsData Data { get; set; } = new();
}
public class AppDetailsData
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("steam_appid")]
public uint SteamAppId { get; set; }
[JsonPropertyName("packages")]
public uint[] Packages { get; set; } = [];
[JsonPropertyName("package_groups")]
public PackageGroup[] PackageGroups { get; set; } = [];
}
public class PackageGroup
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("subs")]
public PackageSubs[] Subs { get; set; } = [];
}
public class PackageSubs
{
[JsonPropertyName("id")]
public uint Id { get; set; }
}
class Program
{
static Task Main(string[] args) => new Program().Run();
private readonly HttpClient _httpClient = new();
public async Task Run()
{
var token = "...";
var result = await AddToCart(2129810, token);
Console.WriteLine($"В корзине {result.Cart.LineItems.Count} предмет(ов) на сумму {result.Cart.Subtotal.FormattedAmount}");
}
public async Task<CAccountCart_AddItemsToCart_Response> AddToCart(uint appId, string accessToken)
{
var url = $"https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1?access_token={accessToken}";
var packageId = await GetGamePackageId(appId);
var inputProtobufEncoded = EncodeAddItemsRequest(packageId);
var boundary = $"----WebKitFormBoundary{DateTime.Now.Ticks:x}";
var protobufContent = new StringContent(inputProtobufEncoded);
using var content = new MultipartFormDataContent(boundary)
{
{ protobufContent, "\"input_protobuf_encoded\"" }
};
content.Headers.ContentType = MediaTypeHeaderValue.Parse($"multipart/form-data; boundary={boundary}");
using var response = await _httpClient.PostAsync(url, content);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStreamAsync();
return CAccountCart_AddItemsToCart_Response.Parser.ParseFrom(responseContent);
}
public string EncodeAddItemsRequest(uint packageId)
{
var request = new CAccountCart_AddItemsToCart_Request
{
UserCountry = "RU"
};
request.Items.Add(new CAccountCart_AddItemsToCart_Request_ItemToAdd
{
Packageid = packageId
});
var protoBytes = request.ToByteArray();
var inputProtobufEncoded = Convert.ToBase64String(protoBytes);
return inputProtobufEncoded;
}
public async Task<uint> GetGamePackageId(uint appId)
{
var response = await _httpClient.GetFromJsonAsync<Dictionary<uint, AppDetailsRoot>>($"https://store.steampowered.com/api/appdetails?appids={appId}");
if (response == null || !response.TryGetValue(appId, out var appDetails) || !appDetails.Success)
{
throw new Exception($"Failed to retrieve app details for appId {appId}");
}
if (appDetails.Data.Packages?.Length is not > 0)
{
throw new Exception($"No packages found for appId {appId}");
}
return appDetails.Data.Packages[0];
}
}
Вот собственно и все. Если и дальше планируете работать со Steam, то советую познать лучше Protobuf, что такое запросы, как работают API. Ибо без этих навыков вы вряд-ли сделаете что-то адекватное. Ну и да, без генераторов! Пока у вас не будет опыта, вот честно, забудьте про них, ибо сами видите как они гадят вам на ровном месте.
Удачи!
Ответ на комментарий про Accept-Language не бесполезен..
Безусловно. Если так разобраться, то любой заголовок делает что-то. НО... В случае с Accept-Language заголовок используется больше для автоматического определения языка пользователя, где сервер может подставить нужную HTML страничку. Ключевое тут именно HTML, ведь дальнейшие API зачастую будут вызывается с конкретными параметрами в URL или теле, не в заголовках.
Примеры:
- Любой сайт Microsoft, допустим, документация (
learn.microsoft.com) - Отправляем запрос на сайт без указания
Accept-Languageи языка в URL > Сайт делает 301 перенаправление на адрес/en-us/...(язык по умолчанию) - Делаем другой запрос, но указываем
Accept-Languageкакes> Сайт перенаправляет на/es-es/....
Вроде все логично и работает, да? Да вот не сказал-бы, ибо мы сейчас говорим про получение контента на нужном языке, а редирект - это не получение контента. Вот уже после редиректа сайт и начинает дергать всяческие API, где указывает прямо в URL язык. Да, некоторые API реагируют на заголовок (делая опять, по большей части редирект), но редирект не есть хорошо, лучше сразу брать данные из конкретного источника.
Другой пример, Steam.
- Заходим через браузер на сайт (предварительно открыв DevTools)
- Обновляем страницу и смотрим на
XHRзапросы и что видим?
- Давайте сменим язык, указав в URL нужные параметры

- Все Protobuf запросы также содержат в теле конкретный язык для работы этих API (это можно увидеть выше, где я расшифровывал их).
А давайте попробуем взять конкретный API и попробуем изменить язык при помощи Accept-Language.
- Открываем Postman, берем тот-же API через который получали ID пакета (
/api/appdetails) и устанавливаем заголовок.
Смотрите, а API не поменял язык. (По наблюдениям, иногда меняет, будто есть кэширование. С параметром в URL такой проблемы нет) - Сделаем тоже самое, но укажем язык в URL. И о чудо, поменял.

Так вот, к чему я это...
Данный заголовок может помочь в тех случаях, когда мы работаем с HTML, но и тут под вопросом, ибо многие сайты умеют определяеть язык по IP и др. данным, а сам заголовок для них мало важен.
А вот что касается API, то в 90% случаев (по моим наблюдениям) они не смотрят на заголовок языка (да и почти на все заголовки) вовсе. Им важны данные и параметры в URL, а не некие динамичные данные, которые могут быть, а могут и не быть. Да и удобней задать в URL/теле нужный параметр, чем соблюдать стандарт заголовка.
Из-за этого я и говорю, что в большинстве случаев для работы с API многие заголовки (включая язык) бесполезны. Но стоит анализировать конкретный сайт, смотреть на его поведение (о чем тоже было сказано в ответе). В данном вопросе мы говорим про конкретны сайт (Steam, причем API!), а для него, как видите, он на 100% бесполезен.