Featured image of post Python监控 Steam 冷门闪卡价格,低价自动邮件提醒

Python监控 Steam 冷门闪卡价格,低价自动邮件提醒

1、引言

steam的闪亮徽章收集一直是个漫长又看不到尽头的过程,一枚徽章由5-15张不同的闪卡合成,一张闪卡的出卡率为1%(网传),而steam限定用户每天只能通过补充包制作三张随机卡牌,又要搏百分之一的概率出闪,又要避免重复,即便运气好,闪卡5缺1,8缺2,10缺3..也是常态了。

闪卡套1 闪卡套2

上图两套闪卡的收集已经跨越将近8个月的时间,纵使想要通过steam市场快速补齐剩下的闪卡,可是这种冷门卡往往超低库存价格离谱,或者干脆没有库存,让人丧失购买欲望。

价格偏高库存稀少

有心栽花花不开,不过每年的四季大促、折扣节日都会有数以万计的闪卡流入市场,平时也有大量闪卡从同样收集徽章的账户中产出上架,但普通人不可能每天都去检索大量冷门闪卡,难点是

  • 闪卡不是主流市场元素,检索复杂,筛选条件苛刻
  • 市场不提供“收藏”的模块,不能一键获取所有目标商品,操作量成倍增长
  • 受网络和请求大量无关元素影响,返回价格速度较慢

收卡本来就是一个任重而道远的过程,财力有限的情况下,集不集得齐主要看缘分,这种时候有心插柳一下,也许能让缘分来得快一点。

价格浮动

2、效果速览

2.1 概览

项目部署在steam卡牌价格监控Github链接。完整的一个卡片监测页面,点击刷新价格可以快速获取不同收藏卡的实时价格,后端每30分钟会自动请求一次,若监测到价格低于设定期望值,就发送邮件提醒。

图片单击放大

2.2 完整操作

首先注册邮箱是用于接收价格邮件的,一旦监测到卡牌的价格低于设定的期望值,Railway会通过resend邮件发送到这个邮箱,一开始即使不输入也可以注册,用于单纯的一键获取价格,后续也可以添加邮箱地址用于价格提醒。

注册

准备好需要监测的商品,打开商品页,复制后缀。

复制后缀

点击添加卡牌,目前仅支持添加steam集换式卡牌,即商品类目为753。

添加追踪卡片

后缀无误的话会提取该卡的价格和卡面,可以设定一个期望价格,这里不妨输入120。

返回价格,设定提醒 设定期望价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

路径拿到了,appidmarket_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,需要去掉人民币符号¥和一个空格后化为Floatvolumemedian_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_urlicon_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是用于市场检索列表的,我们只需要一张大图就行,大小适配都可以在前端调试。

这样三件事就明确了

  1. 要请求的接口是steamcomminuty.com/market/listings/:appid/:hashname/render,参数是currency=23start=0
  2. 要爬取的是Assets包下的icon_url,它的路径嵌套是 “assets”: { “753”: { “6”: { “37456232637”: {“icon_url”:
  3. 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 KEYSCreate 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直接转发邮件,要么设置一个提醒字段,只在第一次监测到时提醒,后续价格变化时重置这个字段

使用 Hugo 构建
主题 StackJimmy 设计
AI助手
点我聊天 !
来自遥远的deepseek