事情是怎么开始的
需求很小:用户输入一个游戏名,我得给出它在 Steam 上的 AppID。
听起来一句话的事。AppID 和游戏名的对应关系,Steam 官方就有现成接口,把全量列表拉下来本地一查就完了。我当时也是这么想的,结果从「拉列表」这一步开始,一个接一个地翻车。
下面这四个坑,按我踩的顺序讲。代码是从一个 Electron + Node 项目里抠出来的,能直接抄。
坑零:你搜到的那个接口,已经没了
网上一搜「Steam 全部游戏列表」,十篇有九篇给你这个:
https://api.steampowered.com/ISteamApps/GetAppList/v2/
不用 Key,一把梭返回所有 app,简单到不行。很多老教程、老库都还在用它。
我也照着抄了,然后收到一句冷冰冰的回复:
Method 'GetAppList' not found in interface 'ISteamApps'
HTTP 404。换 v0002、换 v1、去掉结尾斜杠,全是 404。
一开始我怀疑是自己网络或者域名被墙,于是拿同一个接口下的另一个方法 ISteamApps/UpToDateCheck 试了试,它好好地返回了 200。说明服务器活得好好的,就是 GetAppList 这个方法被摘掉了。
再用 GetSupportedAPIList 查了下 ISteamApps 接口现在还剩哪些方法,清单里只有 GetSDRConfig、GetServersAtAddress、UpToDateCheck 三个,确实没有 GetAppList 的影子。官方文档上那行小字写的是「Deprecated」,但实际体验已经是「调用即 404」。弃用和下线之间,它选择了后者。
那现在该用哪个?只有这一个:
https://api.steampowered.com/IStoreService/GetAppList/v1/
新旧对比一眼看完:
| 能不能调 | 404 Method not found | 能 |
| 要不要 Key | 曾经不要 | 要 |
| 能不能只要游戏 | 不能,全都混在一起 | 能,include_games/dlc/… 自己挑 |
| 一次给多少 | 全给 | 分页,单页上限约 5 万 |
记住一句话就行:别再用 ISteamApps/GetAppList,换 IStoreService 那个。代价是得带 Key、得自己翻页,下面就是翻页的坑。 
坑一:一次请求,拿不全
新接口不会一口气把几十万个 app 倒给你,它分页。每页最多五万条左右,靠两个字段决定要不要翻下一页:
- response.have_more_results 是 true,说明后面还有;
- response.last_appid 是这一页最后一个 appid,把它当作下一页的起点传回去。
先把请求 URL 拼好。我只要游戏和 DLC,软件、视频、硬件这些一律不要,省得后面还得自己筛:
const APP_LIST_PAGE_SIZE = 50000
function buildStoreAppListUrl(baseUrl: string, apiKey: string, lastAppId: number): string {
const params = new URLSearchParams({
key: apiKey,
include_games: '1',
include_dlc: '1',
include_software: '0',
include_videos: '0',
include_hardware: '0',
max_results: String(APP_LIST_PAGE_SIZE),
format: 'json',
})
if (lastAppId > 0) params.set('last_appid', String(lastAppId))
return `${baseUrl}?${params.toString()}`
}
然后是翻页循环。这里我多写了两道看着没用、其实救命的保险:
const MAX_APP_LIST_PAGES = 20 // 页数硬上限
async function fetchSteamAppList(apiKey: string): Promise<SteamAppListItem[]> {
const apps: SteamAppListItem[] = []
let lastAppId = 0
for (let page = 0; page < MAX_APP_LIST_PAGES; page++) {
const json = await fetchSteamAppListJson(apiKey, lastAppId)
const pageApps = json?.response?.apps
if (!Array.isArray(pageApps)) {
throw new Error('Steam AppList 返回结构异常:response.apps 不是数组')
}
apps.push(…pageApps.map(parseSteamApp).filter(Boolean))
if (!json.response?.have_more_results) break
const next = json.response.last_appid ?? 0
if (!Number.isInteger(next) || next <= lastAppId) break // 不前进就停
lastAppId = next
}
return apps
}
为什么要这两道保险?
一是那个 MAX_APP_LIST_PAGES。万一接口抽风,have_more_results 永远是 true,没有它你就是个无限请求机器,配额分分钟刷爆。
二是 next <= lastAppId 就 break。正常情况下 last_appid 应该一页比一页大,但你不能赌它一定如此。一旦某次返回的 last_appid 没往前走,循环就会卡在同一页反复拉同样的数据,外面看着像卡死,里面在空转。把「下一页起点必须严格变大」这个常识写成代码,比相信接口靠谱。
坑二:几十万条数据,每次都重拉就是自找麻烦
这个接口拉的是整个 Steam 商店目录。光是游戏加 DLC 就好几十万条,落到本地 JSON 文件能撑到九十万行。用户每查一个名字就重新拉一遍全量,慢不说,配额也扛不住。
所以正确姿势是落盘缓存,按需刷新:
平时查询直接读本地那份缓存来匹配,根本不碰网络。只有用户手动点「刷新」,或者本地压根没缓存的时候,才真去拉全量。拉回来之后也不是整个覆盖,而是和旧数据按 appid 合并,新的盖旧的,没动过的保留。
合并就用一个 Map 去重,appid 作 key:
function mergeSteamAppListCache(
current: SteamAppListCache | null,
remoteApps: SteamAppListItem[],
now: Date,
): SteamAppListCache {
const byAppId = new Map<number, SteamAppListItem>()
for (const app of current?.apps ?? []) {
if (isValidSteamApp(app)) byAppId.set(app.appid, { appid: app.appid, name: app.name })
}
for (const app of remoteApps) {
if (isValidSteamApp(app)) byAppId.set(app.appid, { appid: app.appid, name: app.name })
}
const apps = […byAppId.values()].sort((a, b) => a.appid – b.appid)
return { version: 1, sourceUrl: STEAM_APP_LIST_SOURCE_URL, updatedAt: now.toISOString(), total: apps.length, apps }
}
顺手提一个容易忽略的点:商店目录里混着不少 appid 是 0、或者名字干脆是空的垃圾条目,入库前得筛掉,不然查询结果里会冒出一堆「无名氏」:
function isValidSteamApp(item: SteamAppListItem): boolean {
return Number.isInteger(item.appid) && item.appid > 0 && item.name.trim() !== ''
}
还有一点关乎体验:刷新失败别让整个功能跟着崩。网络拉取挂了但本地有旧缓存,那就用旧的,顶多附一句提示告诉用户「数据没刷新成」。让用户拿着稍旧的数据照常查,总比甩他一个红色报错强:
} catch (err) {
if (!cache) throw new Error(`Failed to fetch Steam app list: ${err}`)
warning = `刷新 Steam AppList 失败,已使用本地缓存:${stringifyError(err)}`
}
坑三:浏览器能开,代码偏说证书不对
数据能拉了,换台机器又翻车,这回是这句:
unable to verify the first certificate
最让人挠头的是,同一台机器,浏览器打开这个接口好好的,PowerShell 也能通,唯独 Node 代码报证书错误。
原因在于信任源不一样。浏览器、PowerShell 这些走的是 Windows 的证书库,而 Node 用的是自己内置的一份 CA 名单,它压根不读 Windows 证书库。机器上一旦装了会拦截 HTTPS 的东西,比如公司代理或者某些杀毒软件,它们会往系统里塞一张自己的根证书。Windows 认这张证书,所以浏览器没事;Node 不认,于是验签到一半断了,报「第一张证书都验不过」。
如果你的程序是 Electron,最省事的办法是用它的 net 模块兜底。net.fetch 走的是 Chromium 的网络栈,跟浏览器一个待遇,读系统证书库、认系统代理。于是思路就清楚了:正常先用 Node 的 https 发请求,只有撞上证书错误时才退一步用 net.fetch 重发。
GetAppList 这里我还多加了一层。因为除了证书问题,v1 这个路径偶尔会在某些代理或网关后面被改写、变得不可达,所以再准备一个老版本 v0001 的端点垫底。连起来是三级:
const PRIMARY = 'https://api.steampowered.com/IStoreService/GetAppList/v1/'
const FALLBACK = 'https://api.steampowered.com/IStoreService/GetAppList/v0001/'
async function fetchSteamAppListJson(apiKey: string, lastAppId: number) {
const primaryUrl = buildStoreAppListUrl(PRIMARY, apiKey, lastAppId)
const fallbackUrl = buildStoreAppListUrl(FALLBACK, apiKey, lastAppId)
try {
return await fetchJson(primaryUrl) // 1) 先用 Node https
} catch (err) {
if (!isTlsCertificateError(err)) throw err // 不是证书错,照常抛
try {
return await fallbackFetchJson(primaryUrl, err) // 2) 证书错,net.fetch 重发同一个 URL
} catch (fallbackErr) {
if (!isHttp404Error(fallbackErr)) throw new Error(/* 两步都挂 */)
return await fallbackFetchJson(fallbackUrl, err) // 3) 还 404,换 v0001 端点
}
}
}
判断「这是不是证书错误」要精准,别把超时、404 也当证书问题给吞了:
export function isTlsCertificateError(err: unknown): boolean {
const code = isNodeError(err) ? err.code : undefined
if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
code === 'SELF_SIGNED_CERT_IN_CHAIN' ||
code === 'DEPTH_ZERO_SELF_SIGNED_CERT') return true
return /unable to verify the first certificate/i.test(String(err))
}
顺带提一句反面教材:网上有人教你设 rejectUnauthorized: false 把证书校验直接关掉。这等于把家门口的报警器拆了图清静,真有中间人攻击你也察觉不到。能用 net.fetch 就别走这条路。
最后一步:名字怎么查才不死板
列表到手,剩下就是拿用户输入的名字去匹配。
直接 === 比是不行的。大小写、空格、各种标点和商标符号都会让「Half-Life」匹配不上「half life」。所以先把名字归一化:统一小写,把所有非字母数字的字符都替换成空格再合并:
function normalizeSteamAppName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^\\p{L}\\p{N}]+/gu, ' ') // 非字母数字 → 空格
.replace(/\\s+/g, ' ')
.trim()
}
归一化之后再分三档打分:精确相等的排最前,前缀命中的次之,包含关系的垫后;同一档里名字越短越靠前。这样查「Portal」,「Portal」本体稳稳排第一,「Portal 2」「Portal Knights」乖乖跟在后面,不会喧宾夺主。
写在最后
回头看,这个「用名字查 AppID」的小需求,真正花时间的全是接口和环境的脾气,跟业务逻辑没多大关系。把几个要点收一收:
老接口 ISteamApps/GetAppList 别再碰了,调用就是 404,认准 IStoreService/GetAppList/v1。它要 Key、要翻页,翻页循环记得加「页数上限」和「last_appid 必须变大」两道保险。几十万条数据务必本地缓存、增量合并、筛掉垃圾条目,刷新失败也要能退回旧缓存继续用。证书报错八成是 Node 不读系统证书库撞上了代理或杀软,Electron 里用 net.fetch 兜底最干净。查名字之前先归一化,再分精确、前缀、包含三档排。
参考文档
- Steamworks 官方文档 · ISteamApps 接口(GetAppList 的弃用说明就在这):https://partner.steamgames.com/doc/webapi/isteamapps
- Steam Web API 接口速查(社区维护,能直接看每个接口的参数和返回):https://steamapi.xpaw.me/