-
Notifications
You must be signed in to change notification settings - Fork 0
PICS
PICS — the Product Info Catalog Server — is Steam's authoritative registry of app and package metadata. Every app on Steam (games, tools, videos, DLC, playtests) and every package (bundles, subscriptions, licenses) has a PICS entry; the PICS response is the single source of truth for that entry's contents.
You reach PICS through the CM protocol — i.e., through SteamClient after a login. There is no HTTPS front for PICS. If you're just curious about a public app's name / type / depots, an anonymous login is enough; if you want per-license info (depot access, packages you own, DRM specifics), you need a real account login.
Steam data lives across three surfaces with very different characteristics. Pick the right one for the field you want:
| Field | PICS (SteamClient.get_product_info) |
Store HTTP (store.steampowered.com/api/*) |
Web API (api.steampowered.com/*) |
|---|---|---|---|
name, type, oslist, associations
|
✅ authoritative | ❌ | |
| Depots, manifest ids, launch configs, DRM | ✅ only source | ❌ | ❌ |
| Current price, discount, currency | ❌ | ✅ only source | ❌ |
| Review counts / positive-percentage | ❌ | ✅ (/appreviews/<appid>) |
ISteamApps.*, worse ergonomics |
| Vanity URL / SteamID resolution | ❌ | ❌ | ✅ (ISteamUser.ResolveVanityURL) |
| Player counts | ClientGetNumberOfCurrentPlayers (also on SteamClient) |
❌ | ✅ (ISteamUserStats.GetNumberOfCurrentPlayers) |
| Owned games / user library | ❌ (needs Web API key or logged-in account) | ❌ | ✅ (IPlayerService.GetOwnedGames) |
| Batch-friendly? | ✅ 500+ apps per call | ❌ one HTTP round-trip per app | ✅ some endpoints |
| Needs Web API key? | ❌ | ❌ (public endpoints) | ✅ (most endpoints) |
| Rate limit | Per CM session (generous) | Per-IP, ~30 req/min without a key | Per-key, ~100k/day |
Rule of thumb:
-
App metadata by ID (
name,type,depots,oslist,associations, DLC list, developer/publisher, launch configs, DRM keys) → PICS. It's the only source and it batches. - Price / storefront / current discount → store HTTP. PICS doesn't carry price.
-
Reviews / recommendation summary → store HTTP (
/appreviews/<appid>). - Account-scoped data (owned games, wishlist, stats, achievements) → Web API with a key.
- Vanity URL resolution, workshop items, market listings → Web API.
If a data set spans surfaces, hit multiple. The scraper in scripts/steam_scrape/ shows the common pattern: PICS for names in one batched call, store HTTP for per-app price + reviews.
from steam.client import SteamClient
client = SteamClient()
assert client.anonymous_login() == client.EResult.OK
resp = client.get_product_info(
apps=[570, 730, 553850], # Dota 2, CS2, Helldivers 2
timeout=30,
)
for appid, info in resp['apps'].items():
name = info.get('common', {}).get('name')
app_type = info.get('common', {}).get('type')
print(f'{appid}: {name} ({app_type})')
client.logout()
client.disconnect()Output:
570: Dota 2 (game)
730: Counter-Strike 2 (game)
553850: HELLDIVERS™ 2 (game)
get_product_info(
apps=[], # list[int | dict]
packages=[], # list[int | dict]
meta_data_only=False, # bool
raw=False, # bool
auto_access_tokens=True, # bool
timeout=15, # seconds per response chunk
) -> dict | NoneReturns:
{
'apps': {
570: {
'common': {'name': 'Dota 2', 'type': 'game', ...},
'extended': {...},
'config': {...},
'depots': {...},
'ufs': {...},
'_missing_token': False,
'_change_number': 12345678,
'_sha': 'abc123...',
'_size': 4321,
},
...
},
'packages': {
123: {
'packageid': 123,
'billingtype': 10,
'licensetype': 1,
'status': 0,
'extended': {...},
'appids': [...],
'_missing_token': False,
...
},
},
}Returns None if both apps and packages are empty.
-
apps=[…]— list of app ids to fetch. Each item is either an int (570) or a dict with anappidkey and optionalaccess_token. Pass hundreds at a time; PICS pages large lists across multiple response chunks. -
packages=[…]— same shape for package ids. Package tokens live onclient.licenses[<packageid>].access_tokenafter login. -
meta_data_only=True— skip the VDF payload; only_change_number,_sha,_size,_missing_tokencome back. Fast when you're just checking whether an app changed since your last read. -
raw=True— payloads returned as raw bytes under_buffer(text VDF for apps, binary VDF for packages) instead of being parsed into dicts. Useful if you have a pre-built VDF parser. -
auto_access_tokens=True(default) — the client callsget_access_tokens()first and threads the results in. Turn off if you already have tokens or want to skip the extra CM round-trip. -
timeout=15— seconds per response chunk. A batch of 500 apps might come back as 3–4 chunks; the timeout is per-chunk, not per-call.
Some apps' PICS entries are gated on an access token. Anonymous logins can see the "common" data on nearly every public app; certain fields (extended metadata, private betas, unreleased content) require a token, and the entry comes back with _missing_token=True:
resp = client.get_product_info(apps=[555555])
if resp['apps'][555555]['_missing_token']:
tokens = client.get_access_tokens(app_ids=[555555])
resp = client.get_product_info(apps=[{
'appid': 555555,
'access_token': tokens['apps'][555555],
}])Access tokens require an authenticated login for the account that owns / can-see the app — anonymous logins get zeros for anything gated.
Every PICS response includes _change_number. Save it, then poll get_changes_since(change_number) on a schedule to learn which apps and packages have new revisions:
resp = client.get_product_info(apps=[570, 730])
last_change = max(
info['_change_number']
for info in resp['apps'].values()
)
# … later …
changes = client.get_changes_since(last_change, app_changes=True)
for change in changes.app_changes:
print(f'app {change.appid} changed → cn={change.change_number}')This is the same primitive Steam's own launcher uses to know when to re-fetch depot manifests. Way cheaper than re-polling get_product_info for a large watchlist.
Use meta_data_only=True when you only want to know whether something changed:
resp = client.get_product_info(apps=[570, 730], meta_data_only=True)
for appid, info in resp['apps'].items():
print(appid, info['_change_number'], info['_sha'])
# 570 12345678 abc123...
# 730 12345678 def456...The response is dramatically smaller. Combine with a persisted change-number cache to skip full fetches for unchanged apps.
Fetch names for a large watchlist in one batched call, then look them up:
from steam.client import SteamClient
APP_IDS = [570, 730, 440, 4000, 730, 570] # duplicates fine — dedup on return
client = SteamClient()
assert client.anonymous_login() == client.EResult.OK
# Deduplicate first — PICS won't reject dupes but there's no point in wasting
# the wire bytes. A single call handles ~500 apps comfortably.
apps = list(set(APP_IDS))
resp = client.get_product_info(apps=apps, timeout=30)
names = {
appid: info.get('common', {}).get('name')
for appid, info in resp['apps'].items()
}
client.logout()
client.disconnect()
for appid in APP_IDS:
print(appid, names.get(appid))For 500-item batches or larger, split them into chunks of ~500 and iterate — a chunk that big generally arrives across 3–5 PICS response messages, still one get_product_info call.
-
Public method —
SteamClient.get_product_info(via theAppsmixin insteam/client/builtins/apps.py). -
Related methods on the same mixin —
get_access_tokens,get_changes_since,get_app_ticket,get_encrypted_app_ticket,get_depot_key,get_player_count. -
Enums —
EMsg.ClientPICSProductInfoRequest/Response/AccessTokenRequest/Response/ChangesSinceRequest/Responselive insteam/enums/emsg.py. -
Consumer —
steam/client/cdn.pyusesget_product_infoto look up depots for downloads.
- Downloading actual depot files? CDNClient uses PICS internally to resolve depots.
- Need session cookies for
store.steampowered.comafter login? See SteamClient → Web mixin, or stand up WebAuth standalone. - Want to hit
api.steampowered.comendpoints instead? WebAPI is the right surface — but for app metadata by ID, PICS is faster and doesn't need a key.
H47R15/steam — maintained fork of ValvePython/steam. MIT licensed.