May 23, 2018

Programatically Liquidating my Steam Inventory

An adventure in cookie jars and convoluted authentication flows

I don’t know about you, but every so often my Steam Inventory gets a bit out of control. I have no use for Don’t Starve trading cards, but it’s not really worth my time to individually sell each item for a few cents. However, what is worth my time, apparently, is creating a script to do it for me. This turned out to be quite a winding road, which I’ve documented here.

I don’t care how you did it, hook me up bro!

Alright, fine, here you go.

The repo is on Github at adambard/steam-liquidator. Instructions are in the README. This software comes without a warranty; in fact, it probably won’t work for you without a little tweaking. It works on my machine!

Well, at least, it works for now. All of this is probably subject to change on Steam’s part, so it may not work in six months. Also, if you’re at Steam and reading this and are mad at me, well, I should be able to do this without having to MacGuyver together this monstrosity, so please see about adding this feature.

How the magic happens

There is no publically-documented API for Steam that can accomplish this, so it took a fair bit of trial and error. The broad strokes are:

  • Log into Steam (easier said than done)
  • Transfer your authentication to the steamcommunity.com site
  • Find the list of your available inventories (read on for an atrocious hack!)
  • Fetch the list of items available for you to sell
  • Check the price for each item
  • Sell it!

About the Steam website

It’s pretty clear that Steam’s website is a living-dead spaghetti code mess. This is not really anyone’s fault, it’s just not their primary competence, and, c’mon, we’ve all been there.

That said, most of the important endpoints along the way accept www-form-urlencoded parameters and return JSON and cookies, so it actually wasn’t as bad as I thought it might be, although persistent cookies are necessary for anything to work. I solved this by using a persistent RequestsCookieJar provided by requests, and passing it along to every request so it could collect all the various metadata it needed along the way. I’m sure I didn’t need to do this for every request, or keep every cookie, but it’s just much easier to let the cookies enjoy their natural lifespan, as if I was using a regular browser.

Also, none of this is remotely documented, so even if the flow seems straightforward, I spent quite a lot of time fumbling in the dark to get here. You’re welcome, and if you have to make any extensions that aren’t covered by this post for it to work for you, you have my sympathy.

Logging in

Steam’s quite-eccentric login flow looks roughly like this:

  1. Acquire RSA exponents.
  2. Fire off a login request with your username an RSA-encrypted password.
  3. Receive a login failure, because you need 2FA to get anywhere near Steam.
  4. Now fire off another login request, this time containing the single-use nonce that was emailed to you when you attempted to log in the first time.
  5. Having received the requisite info, transfer your login to your destination.

Acquiring RSA exponents

This just needs a POST to https://store.steampowered.com/login/getrsakey/, providing it with your username:

URL_GET_RSA_KEY = 'https://store.steampowered.com/login/getrsakey/'

def get_rsa_key(jar, username):

    resp = requests.post(URL_GET_RSA_KEY, params={'username': username}, cookies=jar)
    assert resp.status_code == 200, "Invalid response code: {}".format(resp.status_code)
    data = resp.json()

    mod = int(data['publickey_mod'], 16)
    exp = int(data['publickey_exp'], 16)

    return {
        'key': make_key(mod, exp),
        'timestamp': data['timestamp']
    }

Having the mod and exponent of the public key, you can construct an RSA key. Luckily, this was pretty easy with the tools provided by cryptography.io:

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers

import base64

BACKEND = default_backend()


def make_key(mod, exp):
    return RSAPublicNumbers(exp, mod).public_key(BACKEND)


def encrypt(key, message):
    return base64.b64encode(key.encrypt(message.encode('utf-8'), PKCS1v15())).decode('utf-8')

Having the tools to create and wield your public key, you can proceed to the login attempt with a kitchen sink of parameters:

URL_LOGIN = 'https://store.steampowered.com/login/dologin/'

def login(jar, username, password):
    rsa = get_rsa_key(jar, username)

    params = {
        'captcha_text': '',
        'captchagid': -1,
        'emailauth': '',
        'emailsteamid': '',
        'loginfriendlyname': '',
        'captcha_text': '',
        'remember_login': False,
        'username': username,
        'rsatimestamp': rsa['timestamp'],
        'password': encrypt(rsa['key'], password)
    }

    resp = requests.post(URL_LOGIN, data=params, cookies=jar)
    assert resp.status_code == 200, "Login failed."

    data = resp.json()
    jar.update(resp.cookies)

    if data['success'] and 'transfer_parameters' in data:
        return data['transfer_parameters']

    elif data.get('emailauth_needed'):
        code = input("Enter the code you received in your email: ")
        params['emailauth'] = code
        params['emailsteamid'] = data['emailsteamid']

        resp = requests.post(URL_LOGIN, data=params, cookies=jar)
        assert resp.status_code == 200, "Login failed."

        jar.update(resp.cookies)
        return data['transfer_parameters']

Some notes on the above:

  • Remember to collect your cookies! I actually don’t know if the jar.update is necessary or if requests do it, but it was easier to just do this than to look it up. Kenneth, if you’re reading this and care to set me straight, I’m @adambard on twitter!
  • I assume steam-authenticator-enabled apps do a similar thing with different parameters. If this is you, bust open your Chrome/Firefox dev tools and figure out what that parameter might be.
  • Upon a successful second login, a transfer_parameters is returned as part of the response. This contain your steam id and a few other things that come in handy later, and is also used directly in the next step. Hereafter I refer to this same dict as auth_ctx because it’s shorter.

Transferring your login

Having logged in to store.steampowered.com, it is now necessary to fire a request to https://steamcommunity.com/login/transfer so you can be logged in there:

URL_STORE_TRANSFER = 'https://steamcommunity.com/login/transfer'

def transfer_login(jar, auth_ctx):
    resp = requests.post(URL_STORE_TRANSFER, auth_ctx, cookies=jar)
    jar.update(resp.cookies)
    return jar

The cookies are the important thing here! Even though they’re the same as the others, they’re now scoped to the correct domain.

Checking market eligibility

Apparently it’s possible to be ineligible to participate in the market unless you’re eligible to do so, and in any case this endpoint sets a cookie that looks relevant.

URL_CHECK_ELIGIBILITY = 'https://steamcommunity.com/market/eligibilitycheck/'

def check_eligibility(jar):
    resp = requests.get(URL_CHECK_ELIGIBILITY, cookies=jar, allow_redirects=False)
    jar.update(resp.cookies)

    return resp.status_code == 302

Retrieving your inventories

This is the only spot where I wasn’t able to use cookies or returned JSON to get what I wanted. Your inventory is actually composed of many inventories, each identified by an app_id and a context_id. The app id is unique to each app (game) you own, and it would be possible to just list all your apps and attempt them, except that a) most apps don’t have inventories, so you’d be wasting a lot of time and resources to check them all, and b) the context_id is an apparently random number that nonetheless must be correct.

I was about to turn to Beautiful Soup to scrape up these numbers, but scanning the returned HTML of the inventory page, another solution presented itself; somewhere in one of the scripts, a big JSON object serialized without line breaks (important!) and assigned to a javascript variable named g_rgAppContextData. Jackpot!

I’m not sure whether to be ashamed or proud of the following:

URL_INVENTORY_PAGE = 'https://steamcommunity.com/profiles/{steam_id}/inventory/'

def extract_inventories(jar, auth_ctx):
    resp = requests.get(URL_INVENTORY_PAGE.format(steam_id=auth_ctx['steamid']), cookies=jar)
    result = re.search(r"g_rgAppContextData = (.*);", resp.text)

    data = json.loads(result.group(1))
    return [(appid, contextid)
           for appid, v in data.items()
           for contextid in v.get('rgContexts', {}) ]

(That last list comprehension traverses the nested data structure and pulls out those numbers I need.)

Listing the contents of an inventory

This part is, for once, a straightforward request with url parameters returning borderline-useable JSON. The only twist is that the response contains two lists of the same content in different shapes, one containing one of the ids I needed, and the other containing the other! But, with some python-fu, it wasn’t too bad to filter and amalgamate them.

URL_INVENTORY = 'https://steamcommunity.com/inventory/{steam_id}/{app_id}/{context_id}'

def list_inventory(jar, auth_ctx, appid, contextid):
    url = URL_INVENTORY.format(
        steam_id=auth_ctx['steamid'],
        app_id=appid,
        context_id=contextid
    )
    resp = requests.get(url, cookies=jar)
    assert resp.status_code == 200

    jar.update(resp.cookies)

    data = resp.json()
    items = zip(data['assets'], data['descriptions'])
    return [dict(item, **asset) for (asset, item) in items
            if item.get('marketable')]
  • Note the presence of a marketable parameter on each item; non-marketable items can’t be sold, so we don’t need to waste time on them

Fetching a price

We’re almost there! The second-to-last thing we need to do is figure out what price we want to sell the thing for. Luckily, we have another sensible endpoint to work with, although it returns strings of dollars that have to be massaged into integer cents. There’s another price history endpoint that does return those, but I don’t care about history for this case, so I just slice off the dollar sign, parse it as a float, multiply by 100, and truncate.

URL_PRICE_OVERVIEW = 'https://steamcommunity.com/market/priceoverview'

def get_price(jar, auth_ctx, item_info):
    params = {
        'appid': item_info['appid'],
        'country': jar['steamCountry'].split('|')[0],
        'currency': item_info['currency'],
        'market_hash_name': item_info['market_hash_name']
    }

    resp = requests.get(URL_PRICE_OVERVIEW, params, cookies=jar)
    try:
        return int(float(resp.json()['lowest_price'][1:]) * 100)
    except:
        logger.warn("No price found for item %s", params['market_hash_name'])

Note that even though I’m in Canada, the currency returned to me appeared to be 0, which I think maps to USD, with a typical price of 8-10 cents. I think this is probably ok, but if your exchange rate differs substantially from ours you may want to double-check this.

Speaking of what country I’m in, that steamCountry parameter contains a country code and some junk I don’t care about separated by a |; I just grab the first part of it.

Selling the damn thing

It’s finally time! This is another sort-of-sane endpoint, although I discovered through trial-and-error that the Referer header is for some reason completely necessary. I’m not sure about the DNT, but it’s easy enough to leave in.

Note the gorgeous array of sources that the parameters are pulled from here. The item contains 3 required ids, the jar another, the price is passed in independently, and although auth_ctx isn’t used in the parameters it is (probably?) necessary to put the steamid in the Referer.

URL_SELL_ITEM = 'https://steamcommunity.com/market/sellitem/'

def sell_item(jar, auth_ctx, item_info, price_cents):
    params = dict(
        amount=1,
        appid=item_info['appid'],
        assetid=item_info['assetid'],
        contextid=item_info['contextid'],
        sessionid=jar['sessionid'],
        price=price_cents
    )

    headers = {
        'Referer': URL_INVENTORY_PAGE.format(steam_id=auth_ctx['steamid']),
        'DNT': '1',
    }

    resp = requests.post(URL_SELL_ITEM, params, cookies=jar, headers=headers)

    assert resp.status_code == 200

    return resp.json()

Hint: If you’re tinkering with this yourself, it’s worth logging the response JSON here.

Easter Egg: This has nothing to do with this post, but if you head to https://adambard.com and mouse over the photograph, you’ll find something I did since my last post that I’m quite proud of.

Pulling it together

With these parts, we can assemble our glorious creation!

def liquidate(username, password):
    jar = requests.cookies.RequestsCookieJar()
    auth_ctx = login(jar, username, password)

    # init session
    resp = requests.get('https://steamcommunity.com/', cookies=jar)
    jar.update(resp.cookies)

    market_jar = transfer_login(jar, auth_ctx)
    check_eligibility(market_jar)

    market_jar['timezoneOffset'] = '-25200,0'


    inventories = extract_inventories(market_jar, auth_ctx)
    for appid, contextid in inventories:
        items = list_inventory(market_jar, auth_ctx, appid, contextid)

        logger.info("Processing %s items for appid=%s, contextid=%s", len(items), appid, contextid)

        for item in items:
            price = get_price(market_jar, auth_ctx, item) 
            if price:
                logger.info("Selling item %s for %s cents", item['market_hash_name'], price)
                sell_item(market_jar, auth_ctx, item, price)

I don’t know what timezoneOffset is used for or even if it’s necessary, but by this point I was getting a bit tired of this whole thing, so I decided simply not to touch it.

Epilogue

If you thought you were done, bad news: your task now is to head to your inbox and click on a few dozen “Confirm” buttons from the flurry of messages you’ve just received from Steam. Still much faster and easier than the manual way, if you didn’t spend all that time writing it!

And so ends our journey, leaving our hero stronger, wiser, and approximately six bucks and one blog post richer. I hope you enjoyed it more than I did.