1、引言
steam的闪亮徽章收集一直是个漫长又看不到尽头的过程,一枚徽章由5-15张不同的闪卡合成,一张闪卡的出卡率为1%(网传),而steam限定用户每天只能通过补充包制作三张随机卡牌,又要搏百分之一的概率出闪,又要避免重复,即便运气好,闪卡5缺1,8缺2,10缺3..也是常态了。
上图两套闪卡的收集已经跨越将近8个月的时间,纵使想要通过steam市场快速补齐剩下的闪卡,可是这种冷门卡往往超低库存价格离谱,或者干脆没有库存,让人丧失购买欲望。
有心栽花花不开,不过每年的四季大促、折扣节日都会有数以万计的闪卡流入市场,平时也有大量闪卡从同样收集徽章的账户中产出上架,但普通人不可能每天都去检索大量冷门闪卡,难点是
- 闪卡不是主流市场元素,检索复杂,筛选条件苛刻
- 市场不提供“收藏”的模块,不能一键获取所有目标商品,操作量成倍增长
- 受网络和请求大量无关元素影响,返回价格速度较慢
收卡本来就是一个任重而道远的过程,财力有限的情况下,集不集得齐主要看缘分,这种时候有心插柳一下,也许能让缘分来得快一点。
2、效果速览
2.1 概览
项目部署在steam卡牌价格监控,Github链接。完整的一个卡片监测页面,点击刷新价格可以快速获取不同收藏卡的实时价格,后端每30分钟会自动请求一次,若监测到价格低于设定期望值,就发送邮件提醒。

2.2 完整操作
首先注册邮箱是用于接收价格邮件的,一旦监测到卡牌的价格低于设定的期望值,Railway会通过resend邮件发送到这个邮箱,一开始即使不输入也可以注册,用于单纯的一键获取价格,后续也可以添加邮箱地址用于价格提醒。
准备好需要监测的商品,打开商品页,复制后缀。
点击添加卡牌,目前仅支持添加steam集换式卡牌,即商品类目为753。
后缀无误的话会提取该卡的价格和卡面,可以设定一个期望价格,这里不妨输入120。
如果邮箱设置并且无误的话,那就会收到一封由resend邮箱转发的提醒邮件了。

3、接口获取
3.1 价格接口
监测闪卡一共要请求两个数据
闪卡的卡面以灰色为底,而普卡以蓝色为底,如果同时监测闪卡和普卡,可以快速定位和区分,同时由不同开发商设计的卡面风格多样,颇具美观度。

在Github上有开发者整理了大量有关steam接口的资料,比如Steam接口整理提供了有关价格和卡面接口的相关信息,但我们要怎么精准找到需要的接口参数呢?随便找三张闪卡的链接为线索
https://steamcommunity.com/market/listings/753/2600700-HAPPY%20HALLOWEEN%20%28Foil%29
https://steamcommunity.com/market/listings/753/2600700-THUNDER%20VOLT%20%28Foil%29
https://steamcommunity.com/market/listings/753/2600700-MILKY%20WAY%20%28Foil%29
观察这三个案例,可以明确闪卡的共同路径都要经过https://steamcommunity.com/market/listings/753,而在Steam接口整理中与market相关的有如下几个接口
一共就十个接口,其实一个个点进去看Response就能找到价格的接口了,不过因为命名还是可以直接挑几个明显的,比如和我们的三个链接最相关的/market/listings,看看它的Request是怎么写的。
Authenticated: No
Method: GET
Host: steamcommunity.com
Path: /market/listings/:appid/:hashname/render
Query Parameters:
| Name |
Type |
Required |
Description |
currency |
number |
No |
Currency ID used for listing prices. Example: 1 – USD, 3 – EUR. |
start |
number |
No |
Index of the first listing to return. Use 0 to start on the first (cheapest) listing. The endpoint responds with 10 listings if there are at least 10 available. |
跟我们的三个链接基本不谋而合,在后面加上/render,两个参数currency代表货币编号,人民币在steam的编号是23,strat就按description里说的设置为0就好。
我们查第一张卡片
https://steamcommunity.com/market/listings/753/2600700-HAPPY%20HALLOWEEN%20%28Foil%29
appid是753,hashname是2600700-HAPPY%20HALLOWEEN%20%28Foil%29,最后拼在一起。
https://steamcommunity.com/market/listings/753/2600700-HAPPY%20HALLOWEEN%20%28Foil%29/render/?currency=23&start=0
看看返回,先记住这张卡的人民币价格为119.98元,然后尽量找这个值就行。
1
2
3
4
5
6
|
import json
import requests
res=requests.get("https://steamcommunity.com/market/listings/753/2600700-HAPPY%20HALLOWEEN%20%28Foil%29/render/?currency=23&start=0")
data=res.json()
print(json.dumps(data, indent=4, ensure_ascii=False))
|
重点肯定是看price相关的键了,比如下面这几个连续的
“steam_fee”: 65,
“publisher_fee”: 131,
“converted_price”: 10434,
“converted_fee”: 1564,
很遗憾的是没有找到119.98元这个值,因为根据参考文档里的Response,能代表价格的字段值都已经超过一万了。
| Name |
Type |
Description |
listinginfo.:listingid.converted_price |
number |
Price converted to the requested currency. |
listinginfo.:listingid.converted_fee |
number |
Converted total fee. |
这其实是一种舍近求远的方法,steam的商品要对卖家要收取一定的税,而税后到手价+税费=我们需要的价格,这里分别是10434+1564=11998,steam对人民币的单位采取的是分制!,而11998分要除100才是我们需要的价格119.98。
仅凭观察字段和这篇英文文档实在是很难得出这个结论了。
不过只要再看几个接口就能迅速解决这个问题,以最可疑的
GET /market/priceoverview
入手,展开Request和Response
Request
Authenticated: No
Method: GET
Host: steamcommunity.com
Path: /market/priceoverview/
Query Parameters:
| Name |
Type |
Required |
Description |
country |
string |
No |
Country code |
currency |
string |
No |
Currency (default: $) |
appid |
string |
Yes |
Steam App ID |
market_hash_name |
string |
Yes |
Item Name |
Response
| Name |
Type |
Description |
success |
boolean |
Whether or not the request was successful |
lowest_price |
string |
The current lowest price at which the item is listed |
volume |
string |
The number of items that has been sold in the last 24 hours (does not exist if volume = 0) |
median_price |
string |
The current median price of current listings |
路径拿到了,appid和market_hash_name我们都有,country先不输入,如果结果有异常再补齐,currency默认是美元,改成人民币编号23。还是以刚才那个商品为例,补齐请求链接后为
https://steamcommunity.com/market/priceoverview/?currency=23&appid=753&market_hash_name=2600700-HAPPY%20HALLOWEEN%20%28Foil%29
1
2
3
4
5
6
7
8
9
10
11
|
import json
import requests
res=requests.get("https://steamcommunity.com/market/priceoverview/?currency=23&appid=753&market_hash_name=2600700-HAPPY%20HALLOWEEN%20%28Foil%29")
data=res.json()
print(json.dumps(data, indent=4, ensure_ascii=False))
|
看一下返回,
1
2
|
"success": true,
"lowest_price": "¥ 119.98"
|
拿到人民币价格了,但是类型是String,需要去掉人民币符号¥和一个空格后化为Float,volume和median_price是因为这款闪卡最近没有成交,所以不返回成交量和成交中位数,其它有成交的卡片都是返回的,虽然这两个返回值我们用不上,但还是在接口返回里保留一下,为以后的功能拓展留个空间。
最终这个价格接口的代码就如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
def get_card_info(card_name:str):
#获取价格
price_url="https://steamcommunity.com/market/priceoverview/"
price_params={
"appid":753,
"currency":23,
"market_hash_name":card_name
}
try:
price_res=requests.get(price_url,params=price_params,timeout=10)
price_data=price_res.json()
if price_data.get("success")==True:
#保留一个String类型,用于给用户显示货币符
lowest_price_str = price_data.get("lowest_price", "无数据")
try:
#String转float类型
lowest_price_float = float(lowest_price_str.replace("¥ ", "").replace(",", ""))
except:
lowest_price_float = None
return {
"success":True,
"lowest_price":lowest_price_str,
"lowest_price_float":lowest_price_float,
"volume":price_data.get("volume","无数据"),
"median_price":price_data.get("median_price","无数据"),
}
return {"success":False,"message":"找不到该商品"}
except Exception as e:
return {"success":False,"message":str(e)}
|
最后留意一下这个接口文档里的请求速率限制:
20 / Per minute
1000 / Per day
在后台的请求暂定为30分钟一次,完全达不到这个速率,所以不必担心。
3.2 闪卡图片接口
Steam接口整理没有提供直接的图片接口,在上文解包Path:/market/listings/:appid/:hashname/render的时候能看到有几个跟图片URL相关的返回:
1
2
3
4
5
6
7
|
"icon_url": "IzMF03bk9WpSBq-S-ekoE33L-iLqGFHVaU25ZzQNQcXdA3g5gMEPvUZZEfSMJ6dESN8p_2SVTY7V2NsJxHMLmD4QPivs0XEwTcRcCPKN6CTXjc26OVHZLj7JLibcQQc6T7UMMTrQqjGh5u2dFjjOEugsQg0DfaJX9mJAa5-KbURs14QOqGf2h0p6WBQnYMFDYjCyx3UUNOB0zndKdJoDzSP5cpeM011nPk5tX7jvALjAZ4mkxSdyW0w0TKkZZt-QpmWyr4G3Z_XdDPMolA",
"icon_url_large": "IzMF03bk9WpSBq-S-ekoE33L-iLqGFHVaU25ZzQNQcXdA3g5gMEPvUZZEfSMJ6dESN8p_2SVTY7V2NsJxHMLmD4QPivs0XEwTcRcCPKN6CTXjc26OVHZLj7JLibcQQc6T7UMMTrQqjGh5u2dFjjOEugsQg0DfaJX9mJAa5-KbURs14QOqGf2h0p6WBQnYMFDYjCyx3UUNOB0zndKdJoDzSP5cpeM011nPk5tX7jvALjAZ4mkxSdyW0w0TKkZZt-QpmWyr4G3Z_XdDPMolA",
"icon": "https://cdn.fastly.steamstatic.com/steamcommunity/public/images/apps/2600700/fbdb73489414b6c73a8ba30a93398244744bacd3.jpg",
"app_icon": "https://cdn.fastly.steamstatic.com/steamcommunity/public/images/apps/753/1d0167575d746dadea7706685c0f3c01c8aeb6d8.jpg",
|
后两个直接访问一下,一个是steam的平台logo,一个是闪卡对应游戏的logo,前两个需要找一下前后缀,打开商品页,F12检查一下图片的源链接。

可以直接定位到元素引用的位置,看当前源

点进去看一下有没有做反爬虫,如果有的话,要考虑找请求头或者加Cookie的方法。
https://community.fastly.steamstatic.com/economy/image/IzMF03bk9WpSBq-S-ekoE33L-iLqGFHVaU25ZzQNQcXdA3g5gMEPvUZZEfSaKqhBT8kyvCOETZfYgdQNwnMMnz4GbDbxyXhoOM1NCPv_iy3ajc6iK1HSRnHNIiDeGQMxT7FYZ2yL92en4e6US2zJRrssQwgAeqIEoWBPNJ_fPEBrhYQI_ma62VRzGVAqfddCdR2Ew3kSNrh4mSdEfZpTyHD3L8Da0AlgbRBiXb3nXurGbYmtkXosCxwxTPMTZ4nBvTuspsDnLPqEkvdNbyE/330x192?allow_animated=1
访问后是直接返回图片的,不放心的话可以多找几个商品页试一下,所需要的所有卡面的共同前缀是
https://community.fastly.steamstatic.com/economy/image/
把icon_url和icon_url_large拼上去
https://community.fastly.steamstatic.com/economy/image/IzMF03bk9WpSBq-S-ekoE33L-iLqGFHVaU25ZzQNQcXdA3g5gMEPvUZZEfSMJ6dESN8p_2SVTY7V2NsJxHMLmD4QPivs0XEwTcRcCPKN6CTXjc26OVHZLj7JLibcQQc6T7UMMTrQqjGh5u2dFjjOEugsQg0DfaJX9mJAa5-KbURs14QOqGf2h0p6WBQnYMFDYjCyx3UUNOB0zndKdJoDzSP5cpeM011nPk5tX7jvALjAZ4mkxSdyW0w0TKkZZt-QpmWyr4G3Z_XdDPMolA
https://community.fastly.steamstatic.com/economy/image/IzMF03bk9WpSBq-S-ekoE33L-iLqGFHVaU25ZzQNQcXdA3g5gMEPvUZZEfSMJ6dESN8p_2SVTY7V2NsJxHMLmD4QPivs0XEwTcRcCPKN6CTXjc26OVHZLj7JLibcQQc6T7UMMTrQqjGh5u2dFjjOEugsQg0DfaJX9mJAa5-KbURs14QOqGf2h0p6WBQnYMFDYjCyx3UUNOB0zndKdJoDzSP5cpeM011nPk5tX7jvALjAZ4mkxSdyW0w0TKkZZt-QpmWyr4G3Z_XdDPMolA
访问后返回的图片是一样大小的(实际上链接也一样),应该是因为商品页只需要一张图片,小图的URL是用于市场检索列表的,我们只需要一张大图就行,大小适配都可以在前端调试。
这样三件事就明确了
- 要请求的接口是
steamcomminuty.com/market/listings/:appid/:hashname/render,参数是currency=23和start=0
- 要爬取的是Assets包下的icon_url,它的路径嵌套是 “assets”: {
“753”: {
“6”: {
“37456232637”: {“icon_url”:
- icon_url要拼凑到https://community.fastly.steamstatic.com/economy/image/后面,返回的就是图片链接了
请求网页json代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import requests
listing_url = f"https://steamcommunity.com/market/listings/753/{card_name}/render"
listing_params={
"start": 0,
#人民币编号=23
"currency": 23,
#"format": "json"
}
listing_res=requests.get(listing_url,params=listing_params,timeout=10)
listing_data=listing_res.json()
|
提取icon_url并拼凑代码
1
2
3
4
5
6
7
8
9
10
11
12
13
|
image_url=None
assets=listing_data.get("assets",{}).get("753",{}).get("6",{})
if assets:
first_asset=list(assets.values())[0]
icon=first_asset.get("icon_url")
if icon:
image_url=f"https://community.fastly.steamstatic.com/economy/image/{icon}"
|
这里要注意上文的37456232637不是一个固定的键,每张卡面的ID是不同的,所以在嵌套提取到“6”的时候就该停止了,并且我们不能保证6的内层只有37456232637这一个字典,所以要把assets当作一个列表,获取其中的第一个字典(因为所有卡面37456232637这样的ID都是是第一个),assets.values()返回的是所有字典,将它转化为list列表之后提取第一个字典即可,然后才能最終获得icon_url的值。
完整代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
import requests
def get_card_info(card_name:str):
#获取价格
price_url="https://steamcommunity.com/market/priceoverview/"
price_params={
"appid":753,
"currency":23,
"market_hash_name":card_name
}
#获取图片
listing_url = f"https://steamcommunity.com/market/listings/753/{card_name}/render"
listing_params={
"start": 0,
"currency": 23,
#"format": "json"
}
try:
price_res=requests.get(price_url,params=price_params,timeout=10)
price_data=price_res.json()
image_url=None
listing_res=requests.get(listing_url,params=listing_params,timeout=10)
listing_data=listing_res.json()
#从assets里提取icon_url
assets=listing_data.get("assets",{}).get("753",{}).get("6",{})
if assets:
first_asset=list(assets.values())[0]
icon=first_asset.get("icon_url")
if icon:
image_url=f"https://community.fastly.steamstatic.com/economy/image/{icon}"
if price_data.get("success")==True:
lowest_price_str = price_data.get("lowest_price", "无数据")
try:
lowest_price_float = float(lowest_price_str.replace("¥ ", "").replace(",", ""))
except:
lowest_price_float = None
return {
"success":True,
"lowest_price":lowest_price_str,
"lowest_price_float":lowest_price_float,
"volume":price_data.get("volume","无数据"),
"median_price":price_data.get("median_price","无数据"),
"image_url":image_url
}
return {"success":False,"message":"找不到该商品"}
except Exception as e:
return {"success":False,"message":str(e)}
|
3.3数据库设计
用户表
| 列名 |
数据类型 |
约束 |
说明 |
| id |
INTEGER |
PRIMARY KEY |
主键 |
| username |
String |
UNIQUE, NOT NULL |
用户名,唯一且不能为空 |
| password |
String |
NOT NULL |
密码,不能为空 |
| email |
String |
UNIQUE |
邮箱地址,唯一 |
邮箱在注册时可以不填,后续需要可以设置
卡牌表
| 列名 |
数据类型 |
约束 |
说明 |
| id |
INTEGER |
PRIMARY KEY |
主键 |
| name |
String |
NOT NULL |
卡片名称 |
| alert_price |
Float |
NULL |
提醒价格,用于触发邮件 |
| last_price |
String |
NULL |
最近一次获取的最低价格,用于触发邮件 |
| owner |
String |
NOT NULL |
卡片所有者,与用户表的username对应 |
| image_url |
String |
NULL |
图片URL |
4. 功能模块
4.1 自动刷新监测
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
def refresh_all_prices():
db=SessionLocal()
cards=db.query(Card).all()
unique_name=list(set(card.name for card in cards))
price_cache={}
for name in unique_name:
price_cache[name]=get_card_info(name)
for card in cards:
result=price_cache.get(card.name)
if result and result["success"]:
card.last_price=result["lowest_price"]
db.commit()
if card.alert_price:
price_value=result.get("lowest_price_float")
if price_value and price_value<=card.alert_price:
user=db.query(User).filter(User.username==card.owner).first()
if user and user.email:
send_email(user.email,card.name,result["lowest_price"],card.alert_price)
db.close()
|
在用户足够多的情况下,一次自动刷新会发出大量请求,list(set(card.name for card in cards))给卡牌进行去重操作,不同用户收藏的相同卡牌只会发出一次请求,可以大大减少请求次数,不过实际也就是我个人使用,假装提升一下可维护性。
1
2
3
4
5
6
|
#定时任务
schedular=BackgroundScheduler()
schedular.add_job(refresh_all_prices,"interval",minutes=30)
schedular.start()
|
做一个定时任务挂载后台,间隔30分钟,可以自己设置
4.2 邮件提醒
目前项目部署在Raliway上且没有开通Pro计划,因此所有直接连接邮件服务器是SMTP的服务都是禁用的,不过可以通过走网页协议发送,比如Resend和SendGrid,额度每天100封,个人使用完全足够,SendGrid目前只对企业开放。
在Resend里找到API KEYS—Create API Key—,复制并保存Token。
在本地文件里配置.env用于处理密钥等隐私数据,这类密钥不会push到github上。

在Railway上以环境变量的方式录入即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
def send_email(to_email:str,card_name:str,price:str,alert_price:float):
resend.api_key=os.getenv("RESEND_API_KEY")
try:
email = resend.Emails.send({
"from": "onboarding@resend.dev",
"to": [to_email],
"subject": "Steam 价格提醒:" + card_name,
"text": "你关注的卡牌 " + card_name + " 当前价格为 " + price + ",已低于你设定的 ¥" + str(alert_price) + "!"
})
print(f"邮件发送成功:{email}")
except Exception as e:
print(f"邮件发送失败,{e}")
|
非SMTP的邮件转发只需要拿到API_KEY,之后的操作执行就不必重复验证了。
5. 缺陷和可改进之处
- 顺序请求的方式不适合用户量大请求数倍增的场景,要么限制用户可监测的卡数,要么提高请求效率(异步),分布式。
- 邮件提醒如果没有第一时间购买的话会按照监测间隔反复发送,极大浪费邮件转发额度,要么换用SMTP直接转发邮件,要么设置一个提醒字段,只在第一次监测到时提醒,后续价格变化时重置这个字段
