我们做了一个免费的小工具:贴上一个 Steam 个人资料链接,它就告诉你这个库存里的物品在 Steam 社区市场上值多少钱。不用登录。背后要做的事是:读取一个公开的库存,给每件物品找到市场价,再加起来。
网站是 Next.js,跑在 Cloudflare Workers 上(通过 OpenNext),数据库是 Supabase Postgres。第一版的做法是:访客在估价页面等待时,由 Worker 直接向 Steam 查价。
然后,2026 年 10 月 19 日,Steam 开始对我们 Cloudflare Worker 发出的请求回 HTTP 429。不是偶尔,是每一次。查价停了。Steam 登录验证也跟着停了——至少一开始我们是这么以为的。
这篇文章讲我们改了什么:一个跑在 Postgres 里的价格爬虫;一个让大多数物品在有人问之前就已经有价格的缓存;针对两种限流的退避规则;"扣掉 Steam 手续费后你实际拿到多少"背后的算法;还有一个关于"别把自己算进去"的教训。
把请求搬到别的地方
我们没办法让 Steam 接受来自 Cloudflare 的请求。但数据库在别处,而 Postgres 可以用 http 扩展发 HTTP 请求。Supabase 也提供 pg_cron。于是爬虫变成了一组 SQL 函数,每分钟跑一次:
create extension if not exists http with schema extensions;
create extension if not exists pg_cron;
select cron.schedule('steam-price-crawl', '* * * * *', $$select public.crawl_tick()$$);
Worker 完全不再向 Steam 查市场价。它的工作缩小成一个问题:这份估价里的每件物品都有价格了吗?
查价用的是 Steam 市场的搜索接口 /market/actions?q=Search。一次请求最多返回 100 件物品,附带以美元计的最低卖价。这比一件物品一次请求好太多了。每个结果存进 price_cache 表,以 (appid, market_hash_name) 为键。
保存函数里有两个小小的防御性选择:
where coalesce((r->>'eCurrency')::int, 1) = 1 -- only USD prices; don't guess other currencies
and steam_price_cents(r->>'strMinSellSubtotal') > 0
如果 Steam 哪天返回了别的货币的价格,我们就跳过,而不是去猜。另外,当爬虫搜索某一件特定物品而结果里没有它时,会把它记成"市场上没有"(source = 'none'),这样就不会每分钟都再搜一次。
这些函数会对外发请求,所以绝对不能让网站访客通过 Supabase API 调用它们。迁移脚本对每一个这类函数都收回了 anon 和 authenticated 的执行权限。
先爬什么
每次 crawl_tick() 做两类工作。
有人在等的物品。 一个叫 crawl_wanted 的函数列出还在估价中、而且没有近期价格的物品。优先(VIP)估价排最前,其余按建立顺序。过去一小时内查价失败三次的物品暂时跳过,免得一个坏掉的名字吃掉全部额度。
整个目录,轮流来。 没有人在等的时候,爬虫会一页一页地翻每个游戏的完整市场列表。每个游戏有各自的刷新间隔:
| App | 游戏 | 刷新间隔 |
|---|---|---|
| 570 | Dota 2 | 6 小时 |
| 730 | Counter-Strike 2 | 6 小时 |
| 440 | Team Fortress 2 | 12 小时 |
| 252490 | Rust | 12 小时 |
| 753 | Steam(卡牌、背景、表情) | 24 小时 |
下一个要爬的游戏,是相对于自己的间隔逾期最久的那个:
order by (cycle_started_at is null) desc,
extract(epoch from now() - cycle_started_at) / (refresh_hours * 3600) desc nulls first,
sort
目录爬取是这个工具感觉起来很快的原因。我们迁移脚本里的一条注释写着:等到有人来问的时候,数据库通常已经有这个库存 95% 以上的价格了。所以单件查价只是用来补漏网之鱼。
两种限流,两种应对
第一条规则很直观:任何 429 都暂停 15 分钟。
if st = 429 then
insert into rate_limits (key, until) values ('crawler', now() + interval '15 minutes')
on conflict (key) do update set until = excluded.until;
end if;
exit when st is distinct from 200; -- any other error: try again next minute
每个请求都会写进 crawl_log 表,结果这成了我们做过最有用的东西。它让我们看到了第二种限流:Steam 也会限制来自 Supabase 地址的请求量。每分钟 8 次请求,大约五小时后就会被封,封大约七小时。在这七小时里,客户的物品也没法估价。
解决办法是别再把额度花在目录上。一个新设置把目录页默认限制在每分钟 2 页,其余额度全部留给有人真的在等的物品:
for i in 1 .. s.crawl_per_tick loop
st := crawl_item(fx); -- someone's item first
if st is null then -- nobody waiting
exit when pages >= s.crawl_pages_per_tick;
pages := pages + 1;
st := crawl_page(fx); -- then one catalogue page
end if;
...
end loop;
我们也放宽了"新鲜"的定义。缓存的价格现在 7 天内有效。单件查价只针对还在估价中的物品。如果估价时间到了某件物品还没有价格,就标成"下次再查",估价照样完成。优先估价最多等一分钟。另一个 pg_cron 任务会把时间到了的估价结束掉,所以就算没人开着页面,估价也会完成。
插曲:不是每个故障都是被封
Steam 登录(OpenID)那天也坏了,很容易就怪到 429 头上。我们加了一个后备方案,把验证请求也通过数据库发出去。但真正的原因其实不一样:Steam 在把我们的 return_to 网址送回来时,把里面的 %2F 解码了,于是签名的值对不上。修法是用一个不带查询字符串的 return_to,要回到哪一页则存在 cookie 里。另外,Steam 的验证接口也会拒绝没有 User-Agent 标头的请求。
教训是:当一个大故障正在发生时,先把每个症状单独查清楚,再决定要不要把它归到那个大故障下面。
扣掉 Steam 手续费后"你实际拿到多少"
大多数人最想看的数字不是市场价,而是卖掉后进到 Steam 钱包的钱。Steam 会从买家付的价格里抽两笔:5% 的 Steam 手续费和 10% 的游戏手续费,每笔最少 1 分。手续费是按卖家拿到的金额算的,所以要从买家价格倒推,需要一个小小的搜索:
/** Seller receives: buyer pays `buyerCents`; Steam takes 5% (min 1 cent) + game 10% (min 1 cent). */
export function sellerReceives(buyerCents: number): number {
const fee = (s: number) => s + Math.max(1, Math.floor(s * 0.05)) + Math.max(1, Math.floor(s * 0.1));
if (buyerCents < 3) return 0;
let s = Math.max(1, buyerCents - 2);
while (s > 1 && fee(s) > buyerCents) s--;
while (fee(s + 1) <= buyerCents) s++;
return s;
}
它找的是:加上手续费后仍然不超过买家价格的最大卖家金额。低于 3 分就什么都卖不了,因为光是两笔最低手续费就要 2 分。
我们也不假设物品刚好能以最低挂单价卖出。想卖得快一点,你会挂得比最低价稍低。所以"你会拿到"这个数字,是按比最低挂单价低约 1% 的价格算的(至少低 1 分,最多低 100 分),再交给 sellerReceives 计算:
export function listPrice(lowestCents: number | null | undefined) {
if (!lowestCents || lowestCents < 1) return null;
const buyer = lowestCents - Math.min(100, Math.max(1, Math.round(lowestCents / 100)));
const seller = sellerReceives(buyer);
return seller >= 1 ? { buyer_cents: buyer, seller_cents: seller } : null;
}
用 Steam 自己的价格算汇率
没登录时,搜索接口给的是美元价,而爬虫是用马来西亚令吉存价格的。我们需要一个汇率,而且希望它跟 Steam 自己换算的方式一致。
所以我们去问 Steam。挑一件一直有人在交易的物品——Team Fortress 2 的"Mann Co. Supply Crate Key"(曼恩公司补给箱钥匙)——查两次它的价格概览:一次用美元,一次用本地货币。汇率就是两个中位价的比值;如果缺中位价,就退而用最低价:
const ref = { appid: 440, name: "Mann Co. Supply Crate Key" };
const usd = await priceOverview(ref.appid, ref.name, 1);
const loc = await priceOverview(ref.appid, ref.name, currency);
const u = usd?.median_cents ?? usd?.lowest_cents, l = loc?.median_cents ?? loc?.lowest_cents;
return u && l ? l / u : null;
爬虫每 12 小时更新一次汇率。失败一次后会等 30 分钟再试,这样一个糟糕的小时不会让每分钟都多浪费两个请求。如果完全没有汇率,这一轮就什么都不做,而不是用一个瞎编的数字换算后存起来。
教训:别把自己算进去
这个网站还有一个可选的付费代售服务。我们用自己的 Steam 账号测试过它。这个账号在后台设置里同时出现在两份名单上:VIP 账号(免费优先估价)和测试账号(这项服务只收 0.50 美元,这是 Stripe 能收的最低金额)。为了确认 Stripe 正式模式能用,我们付的那一笔,就是自己的 0.50 美元信用卡付款。
测试这样做没问题。但这意味着我们自己的活动看起来跟客户一模一样:估价、登录、订单、付款。一个把每一行都算进去的仪表板就会把我们算进去;在冷清的日子里,"我们"可能占了大部分数字。
所以我们给内部仪表板加上 30 天转化漏斗时,从一开始就排除了自己的账号。名单读的是同样那两份设置,而不是写死在代码里:
export function summarizeValuations(vals, logins, orders, own: string[], now: number) {
const mine = new Set(own);
const v = vals.filter((r) => !mine.has(r.steamid));
const o = orders.filter((r) => !mine.has(r.steamid));
// ...counts, sources, sign-ins, orders, paid orders, listings
}
这个接口只返回计数和简短的来源标签,没有 Steam ID,也没有名字。如果你在做一个会有测试付款的东西,早点决定自己的账号怎么标记,并且在同一个地方把它们过滤掉。
试试看
估价工具免费、不用登录:https://steam.2cstudio.app。你的库存需要设为公开。如果某件物品显示"下次再查",只是爬虫还没轮到它。
作者:2C Studio,一间小工作室。