├── star.png
├── sample.png
├── sample2.png
├── sample3.png
├── sample4.png
├── sample5.png
├── sample6.png
├── sample7.png
├── step1.png
├── step2.png
├── step3.png
├── step4.png
├── preferences.png
├── metadata.yaml
├── _conf_schema.json
├── README.md
├── steam_login.py
├── LICENSE
└── main.py
/star.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/star.png
--------------------------------------------------------------------------------
/sample.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample.png
--------------------------------------------------------------------------------
/sample2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample2.png
--------------------------------------------------------------------------------
/sample3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample3.png
--------------------------------------------------------------------------------
/sample4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample4.png
--------------------------------------------------------------------------------
/sample5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample5.png
--------------------------------------------------------------------------------
/sample6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample6.png
--------------------------------------------------------------------------------
/sample7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/sample7.png
--------------------------------------------------------------------------------
/step1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/step1.png
--------------------------------------------------------------------------------
/step2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/step2.png
--------------------------------------------------------------------------------
/step3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/step3.png
--------------------------------------------------------------------------------
/step4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/step4.png
--------------------------------------------------------------------------------
/preferences.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inori-3333/astrbot_plugin_steamshot/HEAD/preferences.png
--------------------------------------------------------------------------------
/metadata.yaml:
--------------------------------------------------------------------------------
1 | name: Check_Steam-Link # 这是你的插件的唯一识别名。
2 | desc: 自动检测消息中的Steam链接,获取商店/个人资料/创意工坊摘要信息&截图。支持指令搜索steam商店游戏与用户,支持保存steam登录状态。使用此插件需要有chrome浏览器(可无头)。 # 插件简短描述
3 | help: 自动解析steam网页链接,支持指令搜索steam商店和steam用户,支持steam登录,具体指令详见Github发布页https://github.com/inori-3333/astrbot_plugin_steamshot/。 # 插件的帮助信息
4 | version: v1.8.6 # 插件版本号。格式:v1.1.1 或者 v1.1
5 | author: Inori-3333 & m4a1deathDawn & haliludaxuanfeng & ZvZPvz # 作者
6 | repo: https://github.com/inori-3333/astrbot_plugin_steamshot # 插件的仓库地址
7 |
--------------------------------------------------------------------------------
/_conf_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "enable_steam_login": {
3 | "type": "bool",
4 | "title": "启用Steam登录",
5 | "description": "是否启用Steam自动登录功能。启用后,插件会自动使用保存的Cookies进行登录。",
6 | "default": false
7 | },
8 | "steam_store_cookies": {
9 | "type": "string",
10 | "title": "Steam商店Cookies",
11 | "description": "Steam商店(store.steampowered.com)的Cookies。获取方法:在浏览器中登录Steam商店,按F12打开开发者工具,切换到'应用/Application/存储/Storage'标签,选择'Cookies' > 'https://store.steampowered.com',复制所有cookies内容。",
12 | "uiType": "textarea",
13 | "uiConfig": {
14 | "rows": 5,
15 | "placeholder": "请粘贴Steam商店的Cookies,如:steamLoginSecure=xxx; steamid=xxx; ..."
16 | }
17 | },
18 | "steam_community_cookies": {
19 | "type": "string",
20 | "title": "Steam社区Cookies",
21 | "description": "Steam社区(steamcommunity.com)的Cookies。获取方法:在浏览器中登录Steam社区,按F12打开开发者工具,切换到'应用/Application/存储/Storage'标签,选择'Cookies' > 'https://steamcommunity.com',复制所有cookies内容。",
22 | "uiType": "textarea",
23 | "uiConfig": {
24 | "rows": 5,
25 | "placeholder": "请粘贴Steam社区的Cookies,如:steamLoginSecure=xxx; steamid=xxx; ..."
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Check_Steam-Link V1.8.6
2 |
3 |
4 |
5 | [](https://opensource.org/licenses/AGPL3.0)
6 | 
7 | [](README.md)
8 | [](https://github.com/inori-3333/astrbot_plugin_steamshot/graphs/contributors)
9 | [](https://github.com/inori-3333/astrbot_plugin_steamshot/commits/main)
10 |
11 |
12 |
13 |
14 |
15 | [](https://github.com/inori-3333/astrbot_plugin_steamshot)
16 |
17 |
18 |
19 | + 一个AstrBot插件。A plugin for AstrBot.
20 | > 如果您觉得对您有用,请点一个star,我会学猫娘叫。
21 | 
22 | > 当前版本:v1.8.6
23 |
24 | # 功能介绍
25 | ## 已实现
26 | 自动检测对话中出现的如下内容,并返回对应页面的网页截图和摘要信息:
27 | - steam商店页链接
28 | - steam个人主页链接
29 | - steam创意工坊链接
30 |
31 | 目前支持的格式如下:
32 | ```
33 | https://store.steampowered.com/app/881020/Granblue_Fantasy_Relink/ # 游戏商店页链接
34 | https://steamcommunity.com/id/inori_333/ # 个人主页链接
35 | https://steamcommunity.com/sharedfiles/filedetails/?id=3472726693 # 创意工坊物品链接
36 | ```
37 | 可解析的信息:
38 | ```
39 | steam商店界面:
40 | 游戏名称
41 | 发行时间
42 | 开发商
43 | 发行商
44 | 游戏类别(保留前五个)
45 | 游戏简介
46 | 游戏评分
47 | 游戏价格
48 | 是否支持中文(包括简体中文和繁体中文)
49 |
50 | steam个人资料界面:
51 | 用户名称
52 | 封禁记录
53 | 个人简介
54 | steam等级
55 | 地区
56 | 当前状态
57 | 游戏数
58 | 好友数
59 | 最新动态
60 |
61 | steam创意工坊界面:
62 | 游戏名称
63 | 创意工坊作品名称
64 | 作者名称
65 | 作者主页链接
66 | 作者状态
67 | 评分
68 | 文件大小
69 | 创建日期
70 | 更新日期
71 | 标签
72 | 描述
73 | ```
74 | ## 待实现
75 | - 返回与链接游戏相关的其他信息,比如从SteamDB获取的价格变化等等。
76 | - 支持参数设置,比如是否需要返回截图,截屏的宽度和高度,返回摘要的详细等级等等。
77 | - 支持解析steam个人隐私允许条件下的所有steam好友的状态,比如好友是否在线,好友正在玩什么游戏等等。
78 | - 支持在搜索steam商店和用户时,有翻页的选项
79 |
80 | # 使用方法
81 | ## 软件依赖
82 | 程序依赖无头参数下的Chrome浏览器进行本地截屏,**您的主机需要安装Chrome浏览器以及对应的ChromeDriver驱动**。
83 | ## 第三方库依赖
84 | 程序依赖以下第三方库:
85 | - selenium
86 | - webdriver-manager
87 | - requests
88 | - beautifulsoup4
89 |
90 | 但是,您应该无需手动安装任何第三方库,也无需手动安装chrome驱动,插件会自动检测您的环境,并安装缺失的库和驱动。
91 | 即,**唯一的必要条件:您的astrbot运行环境需要有Chrome浏览器。**
92 |
93 | ## 自动检测使用示例
94 | _以下两个示例为v1.0.0版本,当前使用效果请查看更新日志中新的示例。_
95 | 
96 | 
97 |
98 | ## 指令使用指南
99 | 根据收到的steam链接自动解析指定界面,插件会自动检测对话中出现的steam链接,并返回对应页面的网页截图和摘要信息(现仅支持steam商店界面、个人主页界面和创意工坊界面)。
100 | ```
101 | 使用 /sss 指令搜索steam商店,使用方法: /sss + 游戏名,如: /sss 不/存在的你,和我
102 | 使用 /ssu 指令搜索steam用户,使用方法: /ssu + 用户名,如: /ssu m4a1_death-Dawn
103 | ```
104 |
105 | 
106 | 
107 |
108 | ```
109 | 使用 /ssl 指令进行steam登录操作,具体使用方法:
110 | /ssl enable - 启用steam登录功能
111 | /ssl disable - 禁用steam登录功能
112 | /ssl status - 查看当前登录状态
113 | /ssl store [cookies文本] - 设置Steam商店cookies
114 | /ssl community [cookies文本] - 设置Steam社区cookies
115 | /ssl test - 测试Steam登录状态
116 | ```
117 |
118 | 注意**steam商店和steam社区的cookies要分开设置**,steam商店的域名对应steampowered.com,steam社区的域名对应steamcommunity.com,**两者cookies不能通用**
119 |
120 | **获取cookies的方法**:打开浏览器 - 进入steam网页登录你的steam账号 - (按F12)调出开发者工具 - 选择网络/Network选项 - 进入一个steam网页 - 选择名称排在最上面那个项 - 右边那个标头/header里面向下拉 - 找到请求标头/request header项 - 把其中的cookies复制
121 |
122 | 这只是一种获取steam cookies的方法,其他还有很多方法,但最好填写完整的cookies,只填写部分的cookies可能会报错
123 | 这就是你的cookies,请保存好,不要泄露,通过指令的方式或者填入Astrbot网页图形ui中插件管理 - Check Steam-Link 插件配置栏中的 input 输入框中
124 |
125 | **强烈建议您在获取steam cookies前,配置好您的偏好设置,避免搜索时出现不必要的bug**
126 | 
127 |
128 | 
129 | 
130 | 
131 | 
132 | 
133 |
134 | **注意:当你进入的是前缀为steampowered.com的steam网页时,对应的cookies是steam商店cookies;当你进入的是前缀为steamcommunity.com的网页时,对应的cookies是steam社区的cookies。请不要填错了!!!**
135 | **注意:当你进入的是前缀为steampowered.com的steam网页时,对应的cookies是steam商店cookies;当你进入的是前缀为steamcommunity.com的网页时,对应的cookies是steam社区的cookies。请不要填错了!!!**
136 | **注意:当你进入的是前缀为steampowered.com的steam网页时,对应的cookies是steam商店cookies;当你进入的是前缀为steamcommunity.com的网页时,对应的cookies是steam社区的cookies。请不要填错了!!!**
137 |
138 | # 已知问题
139 |
140 | + 使用steam登录功能时,cookies有效期只有差不多48小时,48小时后需要更换cookies,猜测是48小时刷新一次cookies,现阶段无法解决,等后续适配steam web api方案
141 | + 解析个人主页时,可能会返回```⚠️ 无法获取个人主页部分信息 + 个人主页截图```,再试1-2次就能正常解析,这个问题无法稳定复现,无有效解决方式,猜测是因为个人主页太多gif导致网页未能完全加载
142 | + 使用/sss和/ssu指令时返回```❌ 搜索失败: 502, message='Attempt to decode JSON with unexpected mimetype: ', url='https://t2i.soulter.top/text2img/generate'```重试1-2次即可正常使用
143 | > 如果有其他问题,请提issue,并附上报错原因,你的系统版本和python版本
144 |
145 | # 更新记录
146 | ## v1.2.0
147 | + 对steam个人主页链接的监听(返回个人主页截图)
148 | + 对游戏商店页内容更详细的解析(返回文本)
149 |
150 | ## v1.3.0
151 | + 修复了发行商异常换行
152 | + 自动获取ChromeDriver
153 | + 异步运行,防止因网络原因卡死astrbot,失败时自动重试
154 |
155 | ## v1.4.0
156 | + 修复了打折游戏价格无法正常显示的bug
157 | + 支持steam网页完整截图
158 |
159 | ## v1.4.5
160 | + 支持绕过steam年龄验证界面
161 |
162 | ## v1.5.0
163 | + 新增支持steam主页解析功能
164 |
165 | ## v1.6.0
166 | + 新增支持steam创意工坊解析功能
167 | 
168 |
169 | ## v1.6.1
170 | + 支持解析steam主页最新动态,并改善排版
171 | + 支持解析steam个人简介中的链接(之前考虑到可能会有些不良链接,不过现在还是觉得应该问题不大,还是放出来了)
172 | 
173 |
174 | ## v1.6.3
175 | + 新增支持steam个人主页封禁记录解析
176 | + 修复了chrome自动更新导致的chromedriver版本不匹配的问题,如果控制台返回chromedriver版本不匹配,重载插件即可解决
177 |
178 | ## v1.6.5
179 | + 修复了steam创意工坊解析的bug
180 | + 完善Steam创意工坊链接处理功能
181 |
182 | ## v1.7.0
183 | + 新增搜索steam商店和搜索steam用户指令
184 |
185 | ## v1.8.0
186 | + 新增支持通过保存cookies的方式登录steam(注意:steam商店和steam社区两个的cookies不一样,请不要填错了)
187 |
188 | ## v1.8.1
189 | + 修复了登录状态下steam商店敏感游戏验证无法跳过的问题
190 | + 修复了登录状态下steam商店可能有些游戏搜不到的问题
191 |
192 | ## v1.8.2
193 | + 优化了steam商店界面价格部分的解析
194 |
195 | ## v1.8.5
196 | + 支持搜索商店和用户时从符合条件的前10项中选择的功能
197 | + 修复了免费游戏和预购游戏价格可能无法解析的问题
198 |
199 | ## v1.8.6
200 | + 修复了低版本pyhton可能会因为{}内包裹的`\`发生`f-string`类型语法错误的问题
201 |
202 | # 支持
203 | [帮助文档](https://github.com/inori-3333/astrbot_plugin_steamshot)
204 |
--------------------------------------------------------------------------------
/steam_login.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import pickle
4 | from datetime import datetime
5 | import re
6 |
7 | # 存储目录和文件
8 | STEAM_AUTH_DIR = "./data/plugins/astrbot_plugin_steamshot/auth"
9 | STEAM_COOKIES_JSON_FILE = os.path.join(STEAM_AUTH_DIR, "steam_cookies.json")
10 |
11 | # 兼容旧版文件路径 (用于迁移)
12 | STEAM_STORE_COOKIES_FILE = os.path.join(STEAM_AUTH_DIR, "steam_store_cookies.pkl")
13 | STEAM_COMMUNITY_COOKIES_FILE = os.path.join(STEAM_AUTH_DIR, "steam_community_cookies.pkl")
14 | STEAM_LOGIN_CONFIG = os.path.join(STEAM_AUTH_DIR, "login_config.json")
15 |
16 | # 定义域名常量
17 | STEAM_STORE_DOMAIN = ".steampowered.com"
18 | STEAM_COMMUNITY_DOMAIN = ".steamcommunity.com"
19 |
20 | def ensure_auth_dir():
21 | """确保认证目录存在"""
22 | os.makedirs(STEAM_AUTH_DIR, exist_ok=True)
23 |
24 | def migrate_from_pickle():
25 | """从旧版的pickle文件迁移到JSON格式"""
26 | if not os.path.exists(STEAM_COOKIES_JSON_FILE) and (
27 | os.path.exists(STEAM_STORE_COOKIES_FILE) or
28 | os.path.exists(STEAM_COMMUNITY_COOKIES_FILE)
29 | ):
30 | print("🔄 检测到旧版cookies文件,正在迁移到JSON格式...")
31 | cookies_data = {
32 | "store": {},
33 | "community": {},
34 | "config": {
35 | "enabled": False,
36 | "store_last_update": None,
37 | "community_last_update": None
38 | }
39 | }
40 |
41 | # 迁移配置
42 | if os.path.exists(STEAM_LOGIN_CONFIG):
43 | try:
44 | with open(STEAM_LOGIN_CONFIG, "r", encoding="utf-8") as f:
45 | cookies_data["config"] = json.load(f)
46 | except Exception as e:
47 | print(f"⚠️ 迁移配置文件失败: {e}")
48 |
49 | # 迁移商店cookies
50 | if os.path.exists(STEAM_STORE_COOKIES_FILE):
51 | try:
52 | with open(STEAM_STORE_COOKIES_FILE, "rb") as f:
53 | cookies_data["store"] = pickle.load(f)
54 | print("✅ 商店cookies迁移成功")
55 | except Exception as e:
56 | print(f"⚠️ 商店cookies迁移失败: {e}")
57 |
58 | # 迁移社区cookies
59 | if os.path.exists(STEAM_COMMUNITY_COOKIES_FILE):
60 | try:
61 | with open(STEAM_COMMUNITY_COOKIES_FILE, "rb") as f:
62 | cookies_data["community"] = pickle.load(f)
63 | print("✅ 社区cookies迁移成功")
64 | except Exception as e:
65 | print(f"⚠️ 社区cookies迁移失败: {e}")
66 |
67 | # 保存为JSON格式
68 | save_cookies_data(cookies_data)
69 | print("✅ 迁移完成,数据已保存为JSON格式")
70 |
71 | # 可选:备份并删除旧文件
72 | try:
73 | if os.path.exists(STEAM_STORE_COOKIES_FILE):
74 | os.rename(STEAM_STORE_COOKIES_FILE, f"{STEAM_STORE_COOKIES_FILE}.bak")
75 | if os.path.exists(STEAM_COMMUNITY_COOKIES_FILE):
76 | os.rename(STEAM_COMMUNITY_COOKIES_FILE, f"{STEAM_COMMUNITY_COOKIES_FILE}.bak")
77 | if os.path.exists(STEAM_LOGIN_CONFIG):
78 | os.rename(STEAM_LOGIN_CONFIG, f"{STEAM_LOGIN_CONFIG}.bak")
79 | print("✅ 旧文件已备份")
80 | except Exception as e:
81 | print(f"⚠️ 备份旧文件失败: {e}")
82 |
83 | def get_cookies_data():
84 | """获取所有cookie数据"""
85 | ensure_auth_dir()
86 | migrate_from_pickle() # 检查并迁移旧数据
87 |
88 | if not os.path.exists(STEAM_COOKIES_JSON_FILE):
89 | # 初始化默认数据结构
90 | default_data = {
91 | "store": {},
92 | "community": {},
93 | "config": {
94 | "enabled": False,
95 | "store_last_update": None,
96 | "community_last_update": None
97 | }
98 | }
99 | save_cookies_data(default_data)
100 | return default_data
101 |
102 | try:
103 | with open(STEAM_COOKIES_JSON_FILE, "r", encoding="utf-8") as f:
104 | return json.load(f)
105 | except Exception as e:
106 | print(f"❌ 读取cookies数据失败: {e}")
107 | # 出错时返回默认数据
108 | return {
109 | "store": {},
110 | "community": {},
111 | "config": {
112 | "enabled": False,
113 | "store_last_update": None,
114 | "community_last_update": None
115 | }
116 | }
117 |
118 | def save_cookies_data(data):
119 | """保存所有cookie数据"""
120 | ensure_auth_dir()
121 | try:
122 | with open(STEAM_COOKIES_JSON_FILE, "w", encoding="utf-8") as f:
123 | json.dump(data, f, ensure_ascii=False, indent=2)
124 | return True
125 | except Exception as e:
126 | print(f"❌ 保存cookies数据失败: {e}")
127 | return False
128 |
129 | def get_login_status():
130 | """获取当前登录状态配置"""
131 | data = get_cookies_data()
132 | return data["config"]
133 |
134 | def save_login_config(config):
135 | """保存登录状态配置"""
136 | data = get_cookies_data()
137 | data["config"] = config
138 | return save_cookies_data(data)
139 |
140 | def enable_steam_login():
141 | """启用Steam登录功能"""
142 | data = get_cookies_data()
143 | data["config"]["enabled"] = True
144 | return save_cookies_data(data)
145 |
146 | def disable_steam_login():
147 | """禁用Steam登录功能"""
148 | data = get_cookies_data()
149 | data["config"]["enabled"] = False
150 | return save_cookies_data(data)
151 |
152 | def parse_cookies_string(cookies_str):
153 | """
154 | 将cookies字符串解析为字典
155 | 参数:
156 | - cookies_str: 用户输入的cookies字符串,通常为 name=value; name2=value2; 格式
157 | 返回:
158 | - dict: 解析后的cookies字典
159 | """
160 | cookies_dict = {}
161 | for cookie in cookies_str.split(';'):
162 | if not cookie.strip():
163 | continue
164 | parts = cookie.strip().split('=', 1)
165 | if len(parts) != 2: # 跳过无效的cookie
166 | continue
167 | name, value = parts
168 | cookies_dict[name.strip()] = value.strip()
169 | return cookies_dict
170 |
171 | def save_steam_cookies(cookies_str, domain_type="store"):
172 | """
173 | 保存Steam Cookies
174 | 参数:
175 | - cookies_str: 用户输入的cookies字符串,通常为 name=value; name2=value2; 格式
176 | - domain_type: 域名类型,"store" 或 "community"
177 | 返回:
178 | - (bool, str): 成功与否及提示信息
179 | """
180 | ensure_auth_dir()
181 |
182 | # 选择正确的域名类型和配置键
183 | if domain_type == "store":
184 | config_key = "store_last_update"
185 | domain_name = "商店(Store)"
186 | elif domain_type == "community":
187 | config_key = "community_last_update"
188 | domain_name = "社区(Community)"
189 | else:
190 | return False, f"❌ 不支持的域名类型: {domain_type}"
191 |
192 | try:
193 | # 解析cookies字符串
194 | cookies_dict = parse_cookies_string(cookies_str)
195 |
196 | # 检查是否包含必要的Steam身份验证cookie
197 | if 'steamLoginSecure' not in cookies_dict:
198 | return False, f"❌ 缺少必要的Steam {domain_name} Cookie: steamLoginSecure"
199 |
200 | # 尝试从steamLoginSecure中提取steamid (可选)
201 | if 'steamLoginSecure' in cookies_dict:
202 | # steamLoginSecure通常格式为: steamid%7C%7Ctoken
203 | # %7C 是 | 的URL编码
204 | match = re.match(r'(\d+)(?:%7C%7C|\|\|)', cookies_dict['steamLoginSecure'])
205 | if match:
206 | steamid = match.group(1)
207 | if steamid and 'steamid' not in cookies_dict:
208 | cookies_dict['steamid'] = steamid
209 | print(f"✓ 从{domain_name} steamLoginSecure中提取到steamid: {steamid}")
210 |
211 | # 获取当前数据并更新
212 | data = get_cookies_data()
213 | data[domain_type] = cookies_dict
214 | data["config"][config_key] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
215 | data["config"]["enabled"] = True # 自动启用登录
216 |
217 | # 保存更新后的数据
218 | if save_cookies_data(data):
219 | return True, f"✅ Steam {domain_name} 登录信息已保存,已启用登录功能"
220 | else:
221 | return False, f"❌ 保存Steam {domain_name} Cookie失败"
222 | except Exception as e:
223 | return False, f"❌ 保存Steam {domain_name} Cookie失败: {e}"
224 |
225 | def load_steam_cookies(domain_type="store"):
226 | """
227 | 加载Steam Cookies
228 | 参数:
229 | - domain_type: 域名类型,"store" 或 "community"
230 | 返回:
231 | - 字典形式的cookies或None
232 | """
233 | if domain_type not in ["store", "community"]:
234 | print(f"❌ 不支持的域名类型: {domain_type}")
235 | return None
236 |
237 | data = get_cookies_data()
238 | cookies = data.get(domain_type, {})
239 |
240 | # 检查是否为空
241 | if not cookies:
242 | return None
243 |
244 | return cookies
245 |
246 | def apply_cookies_to_driver(driver, url=None):
247 | """
248 | 将保存的cookies应用到WebDriver,根据URL自动选择应用的cookie域
249 | 参数:
250 | - driver: Selenium WebDriver实例
251 | - url: 目标URL,用于确定应用哪个域的cookies
252 | 返回:
253 | - bool: 是否成功应用了cookies
254 | """
255 | data = get_cookies_data()
256 | if not data["config"]["enabled"]:
257 | return False
258 |
259 | # 根据URL确定域名类型
260 | domain_type = "store"
261 | domain = STEAM_STORE_DOMAIN
262 |
263 | if url and "steamcommunity.com" in url:
264 | domain_type = "community"
265 | domain = STEAM_COMMUNITY_DOMAIN
266 |
267 | # 加载对应域名的cookies
268 | cookies = data.get(domain_type, {})
269 | if not cookies:
270 | print(f"⚠️ 未找到 {domain_type} 域的cookies")
271 | return False
272 |
273 | try:
274 | # 需要先访问相应域名才能添加cookies
275 | initial_url = "https://store.steampowered.com" if domain_type == "store" else "https://steamcommunity.com"
276 | driver.get(initial_url)
277 |
278 | # 检查当前URL是否与预期域名匹配
279 | current_url = driver.current_url.lower()
280 | if (domain_type == "store" and "steampowered.com" not in current_url) or \
281 | (domain_type == "community" and "steamcommunity.com" not in current_url):
282 | print(f"⚠️ 当前URL ({current_url}) 与目标域名 ({domain_type}) 不匹配")
283 | return False
284 |
285 | # 添加cookies
286 | cookies_added = 0
287 | for name, value in cookies.items():
288 | try:
289 | driver.add_cookie({
290 | 'name': name,
291 | 'value': value,
292 | 'domain': domain
293 | })
294 | cookies_added += 1
295 | except Exception as e:
296 | # 如果某个cookie添加失败,记录但继续处理其他cookies
297 | print(f"⚠️ 添加{domain_type} cookie '{name}'失败: {e}")
298 |
299 | # 刷新页面以应用cookies
300 | driver.refresh()
301 | print(f"✅ 已应用 {cookies_added} 个 {domain_type} cookies")
302 | return cookies_added > 0
303 | except Exception as e:
304 | print(f"❌ 应用Steam {domain_type} Cookie失败: {e}")
305 | return False
306 |
307 | def get_cookie_status():
308 | """获取当前cookie状态信息"""
309 | data = get_cookies_data()
310 | config = data["config"]
311 | store_cookies = data.get("store", {})
312 | community_cookies = data.get("community", {})
313 |
314 | if not config["enabled"]:
315 | return "🔴 当前未启用Steam登录"
316 |
317 | status_lines = []
318 |
319 | # 检查是否有任何cookies
320 | if not store_cookies and not community_cookies:
321 | return "⚠️ 已启用Steam登录,但未找到任何有效的Cookie"
322 |
323 | # 商店cookies状态
324 | if store_cookies:
325 | store_login_secure = store_cookies.get('steamLoginSecure', None)
326 | store_steamid = store_cookies.get('steamid', None)
327 | store_update = config.get("store_last_update", "未知")
328 |
329 | store_status = f"🟢 Steam商店(Store)登录已配置 (更新: {store_update})"
330 | if store_login_secure:
331 | store_status += "\n ✓ 已保存steamLoginSecure"
332 | else:
333 | store_status += "\n ⚠️ 未找到steamLoginSecure"
334 |
335 | if store_steamid:
336 | store_status += f"\n ✓ steamid: {store_steamid}"
337 |
338 | store_status += f"\n 📝 共 {len(store_cookies)} 个cookies"
339 | status_lines.append(store_status)
340 | else:
341 | status_lines.append("⚠️ 未配置Steam商店(Store)登录")
342 |
343 | # 社区cookies状态
344 | if community_cookies:
345 | community_login_secure = community_cookies.get('steamLoginSecure', None)
346 | community_steamid = community_cookies.get('steamid', None)
347 | community_update = config.get("community_last_update", "未知")
348 |
349 | community_status = f"🟢 Steam社区(Community)登录已配置 (更新: {community_update})"
350 | if community_login_secure:
351 | community_status += "\n ✓ 已保存steamLoginSecure"
352 | else:
353 | community_status += "\n ⚠️ 未找到steamLoginSecure"
354 |
355 | if community_steamid:
356 | community_status += f"\n ✓ steamid: {community_steamid}"
357 |
358 | community_status += f"\n 📝 共 {len(community_cookies)} 个cookies"
359 | status_lines.append(community_status)
360 | else:
361 | status_lines.append("⚠️ 未配置Steam社区(Community)登录")
362 |
363 | return "\n\n".join(status_lines)
364 |
365 | def verify_steam_login(driver, domain_type="store"):
366 | """
367 | 验证Steam登录状态是否有效
368 | 参数:
369 | - driver: Selenium WebDriver实例
370 | - domain_type: 域名类型,"store" 或 "community"
371 | 返回:
372 | - (bool, str): 登录状态和用户名(如有)
373 | """
374 | from selenium.webdriver.common.by import By
375 | from selenium.webdriver.support.ui import WebDriverWait
376 | from selenium.webdriver.support import expected_conditions as EC
377 | import time
378 |
379 | try:
380 | # 访问对应的Steam页面
381 | if domain_type == "store":
382 | driver.get("https://store.steampowered.com/")
383 | domain_name = "商店(Store)"
384 | else:
385 | driver.get("https://steamcommunity.com/")
386 | domain_name = "社区(Community)"
387 |
388 | time.sleep(2)
389 |
390 | # 尝试从cookies中提取steamid以获取更多信息
391 | steam_id = None
392 | for cookie in driver.get_cookies():
393 | if cookie['name'] == 'steamid':
394 | steam_id = cookie['value']
395 | break
396 |
397 | # 首先尝试通用的获取用户名方法(适用于两个域)
398 | # 方法1: 检查顶部导航栏中的账户名元素
399 | try:
400 | account_menu = WebDriverWait(driver, 5).until(
401 | EC.presence_of_element_located((By.ID, "account_pulldown"))
402 | )
403 | if account_menu:
404 | username = account_menu.text.strip()
405 | if username and username not in ["登录", "Sign In", "登入", "Connexion", "Anmelden"]:
406 | return True, username
407 | except:
408 | pass
409 |
410 | # 特定于商店页面的检查
411 | if domain_type == "store":
412 | # 方法2: 检查账户下拉菜单中是否有"查看个人资料"链接
413 | try:
414 | driver.find_element(By.ID, "account_pulldown").click()
415 | time.sleep(1)
416 |
417 | profile_links = driver.find_elements(By.XPATH,
418 | "//a[contains(@href, '/profiles/') or contains(@href, '/id/')]")
419 |
420 | if profile_links:
421 | for link in profile_links:
422 | if "profile" in link.get_attribute("href").lower():
423 | # 尝试获取个人资料中的用户名
424 | try:
425 | driver.get(link.get_attribute("href"))
426 | time.sleep(2)
427 | name_element = driver.find_element(By.CLASS_NAME, "actual_persona_name")
428 | if name_element:
429 | return True, name_element.text.strip()
430 | except:
431 | pass
432 | return True, f"{domain_name}已登录 (未获取到用户名)"
433 | except:
434 | pass
435 |
436 | # 特定于社区页面的检查
437 | else:
438 | # 如果有steamid,尝试直接访问个人资料页面获取用户名
439 | if steam_id:
440 | try:
441 | driver.get(f"https://steamcommunity.com/profiles/{steam_id}")
442 | time.sleep(2)
443 | name_element = driver.find_element(By.CLASS_NAME, "actual_persona_name")
444 | if name_element:
445 | return True, name_element.text.strip()
446 | except:
447 | pass
448 |
449 | # 尝试从社区页面上找到个人资料链接
450 | try:
451 | profile_links = driver.find_elements(By.XPATH,
452 | "//a[contains(@href, '/profiles/') or contains(@href, '/id/')]")
453 |
454 | if profile_links:
455 | for link in profile_links:
456 | if "myprofile" in link.get_attribute("href").lower() or "my/profile" in link.get_attribute("href").lower():
457 | try:
458 | driver.get(link.get_attribute("href"))
459 | time.sleep(2)
460 | name_element = driver.find_element(By.CLASS_NAME, "actual_persona_name")
461 | if name_element:
462 | return True, name_element.text.strip()
463 | except:
464 | pass
465 | except:
466 | pass
467 |
468 | # 检查社区页面上的其他用户名指示器
469 | try:
470 | user_panel = driver.find_element(By.ID, "global_header")
471 | if user_panel:
472 | user_links = user_panel.find_elements(By.XPATH, ".//a[contains(@class, 'username')]")
473 | if user_links and len(user_links) > 0:
474 | return True, user_links[0].text.strip()
475 | except:
476 | pass
477 |
478 | # 查找社区页面上的steamcommunity_header
479 | try:
480 | header_element = driver.find_element(By.ID, "steamcommunity_header")
481 | if header_element:
482 | persona_links = header_element.find_elements(By.XPATH, ".//span[contains(@class, 'persona')]")
483 | if persona_links and len(persona_links) > 0:
484 | return True, persona_links[0].text.strip()
485 | except:
486 | pass
487 |
488 | # 通用方法: 检查是否有登出按钮
489 | try:
490 | logout_links = driver.find_elements(By.XPATH,
491 | "//a[contains(@href, 'logout')]")
492 |
493 | if logout_links:
494 | # 如果找到登出按钮,但没找到用户名,尝试从cookies查找steamLoginSecure
495 | for cookie in driver.get_cookies():
496 | if cookie['name'] == 'steamLoginSecure':
497 | # 尝试从steamLoginSecure提取steamid
498 | import re
499 | match = re.match(r'(\d+)(?:%7C%7C|\|\|)', cookie['value'])
500 | if match:
501 | steam_id = match.group(1)
502 | # 如果是社区域,尝试访问个人资料页面
503 | if domain_type == "community" and steam_id:
504 | try:
505 | driver.get(f"https://steamcommunity.com/profiles/{steam_id}")
506 | time.sleep(2)
507 | name_element = driver.find_element(By.CLASS_NAME, "actual_persona_name")
508 | if name_element:
509 | return True, name_element.text.strip()
510 | except:
511 | pass
512 | # 如果没有成功,但至少我们确认已登录
513 | return True, f"{domain_name}已登录 (从steamLoginSecure确认)"
514 | return True, f"{domain_name}已登录 (通过登出按钮确认)"
515 | except:
516 | pass
517 |
518 | # 通用方法: 检查页面源代码中是否包含某些只有登录用户才会有的标记
519 | page_source = driver.page_source.lower()
520 | if "account_name" in page_source or "accountname" in page_source:
521 | # 尝试从页面源码提取用户名
522 | import re
523 | match = re.search(r'account_name["\s:>]+([^<>"]+)', page_source)
524 | if match:
525 | return True, match.group(1)
526 |
527 | # 另一种模式,特别是社区页面
528 | match = re.search(r'class="persona\s+([^"]+)"', page_source)
529 | if match:
530 | return True, match.group(1)
531 |
532 | # 如果我们有steamid,尝试通过直接访问个人资料页面获取名称
533 | if steam_id and domain_type == "community":
534 | try:
535 | driver.get(f"https://steamcommunity.com/profiles/{steam_id}")
536 | time.sleep(2)
537 | name_element = driver.find_element(By.CLASS_NAME, "actual_persona_name")
538 | if name_element:
539 | return True, name_element.text.strip()
540 | except:
541 | pass
542 |
543 | return True, f"{domain_name}已登录 (通过页面源码确认)"
544 |
545 | return False, f"{domain_name}未登录"
546 | except Exception as e:
547 | print(f"❌ 验证Steam {domain_name}登录状态失败: {e}")
548 | return False, f"{domain_name}验证失败: {str(e)}"
549 |
550 | async def test_steam_login():
551 | """测试Steam登录状态"""
552 | from selenium import webdriver
553 | from selenium.webdriver.chrome.options import Options
554 | from selenium.webdriver.chrome.service import Service
555 | from webdriver_manager.chrome import ChromeDriverManager
556 |
557 | store_driver = None
558 | community_driver = None
559 | try:
560 | options = Options()
561 | options.add_argument("--headless")
562 | options.add_argument("--disable-gpu")
563 | options.add_argument("--no-sandbox")
564 |
565 | service = Service(ChromeDriverManager().install())
566 |
567 | # 测试商店登录
568 | store_driver = webdriver.Chrome(service=service, options=options)
569 | store_success = apply_cookies_to_driver(store_driver, "https://store.steampowered.com")
570 | store_status, store_username = verify_steam_login(store_driver, "store")
571 |
572 | # 测试社区登录 - 使用新的driver实例
573 | community_driver = webdriver.Chrome(service=service, options=options)
574 | community_success = apply_cookies_to_driver(community_driver, "https://steamcommunity.com")
575 | community_status, community_username = verify_steam_login(community_driver, "community")
576 |
577 | # 整合结果
578 | result_lines = []
579 |
580 | # 清理用户名中的状态描述文本
581 | if store_status:
582 | # 清理可能混入的状态描述
583 | clean_store_username = store_username
584 | if "已登录" in store_username:
585 | clean_store_username = "获取用户名失败"
586 | result_lines.append(f"✅ Steam商店(Store)登录成功! 用户名: {clean_store_username}")
587 | else:
588 | store_cookies = load_steam_cookies("store")
589 | if store_cookies and 'steamLoginSecure' in store_cookies:
590 | result_lines.append(f"❌ Steam商店(Store)登录失败: Cookie可能已过期或无效。{store_username}")
591 | else:
592 | result_lines.append(f"❌ Steam商店(Store)登录失败: 缺少必要的Cookie。{store_username}")
593 |
594 | # 同样清理社区用户名
595 | if community_status:
596 | clean_community_username = community_username
597 | if "已登录" in community_username:
598 | clean_community_username = "获取用户名失败"
599 | result_lines.append(f"✅ Steam社区(Community)登录成功! 用户名: {clean_community_username}")
600 | else:
601 | community_cookies = load_steam_cookies("community")
602 | if community_cookies and 'steamLoginSecure' in community_cookies:
603 | result_lines.append(f"❌ Steam社区(Community)登录失败: Cookie可能已过期或无效。{community_username}")
604 | else:
605 | result_lines.append(f"❌ Steam社区(Community)登录失败: 缺少必要的Cookie。{community_username}")
606 |
607 | return "\n".join(result_lines)
608 | except Exception as e:
609 | return f"❌ 测试Steam登录出错: {e}"
610 | finally:
611 | if store_driver:
612 | store_driver.quit()
613 | if community_driver:
614 | community_driver.quit()
615 |
616 | # 新增函数:从配置加载和设置登录信息
617 | def load_from_config(config):
618 | """
619 | 从配置对象加载Steam登录设置
620 | 参数:
621 | - config: AstrBot插件配置对象
622 | 返回:
623 | - bool: 是否成功加载
624 | """
625 | try:
626 | # 读取登录开关
627 | enable_login = config.get("enable_steam_login", False)
628 |
629 | # 读取商店cookies
630 | store_cookies = config.get("steam_store_cookies", "")
631 | if store_cookies:
632 | save_steam_cookies(store_cookies, "store")
633 |
634 | # 读取社区cookies
635 | community_cookies = config.get("steam_community_cookies", "")
636 | if community_cookies:
637 | save_steam_cookies(community_cookies, "community")
638 |
639 | # 设置登录状态
640 | if enable_login:
641 | enable_steam_login()
642 | else:
643 | disable_steam_login()
644 |
645 | return True
646 | except Exception as e:
647 | print(f"❌ 从配置加载Steam登录设置失败: {e}")
648 | return False
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C) 2022-2099 AstrBot Plugin Authors
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import sys
3 | import os
4 | import time
5 | import winreg
6 |
7 | def install_missing_packages():
8 | required_packages = ["selenium", "requests", "bs4", "webdriver-manager"]
9 | for package in required_packages:
10 | try:
11 | __import__(package)
12 | except ImportError:
13 | print(f"⚠️ {package} 未安装,正在自动安装...")
14 | subprocess.run([sys.executable, "-m", "pip", "install", package], check=True)
15 |
16 | install_missing_packages()
17 |
18 | # **🔹 依赖导入**
19 | import ssl
20 | import re
21 | import asyncio
22 | import requests
23 | from bs4 import BeautifulSoup
24 | from selenium import webdriver
25 | from selenium.webdriver.chrome.service import Service
26 | from selenium.webdriver.chrome.options import Options
27 | from selenium.webdriver.support.ui import WebDriverWait, Select
28 | from selenium.webdriver.support import expected_conditions as EC
29 | from selenium.webdriver.common.by import By
30 | from webdriver_manager.chrome import ChromeDriverManager
31 | from astrbot.api.event import filter, AstrMessageEvent
32 | from astrbot.api.star import Context, Star, register
33 | from astrbot.api.all import *
34 | import astrbot.api.message_components as Comp
35 | from astrbot.core.utils.session_waiter import (
36 | session_waiter,
37 | SessionController,
38 | )
39 | from jinja2 import Template
40 | import json
41 | # 从steam_login导入需要的函数,但不在顶层使用
42 | from .steam_login import apply_cookies_to_driver, get_login_status
43 |
44 | # 用户状态跟踪
45 | USER_STATES = {}
46 |
47 | # **🔹 Steam 链接匹配正则**
48 | STEAM_URL_PATTERN = r"https://store\.steampowered\.com/app/(\d+)/[\w\-]+/?"
49 | STEAM_PROFILE_URL_PATTERN = r"https://steamcommunity\.com/(profiles/\d{17}|id/[A-Za-z0-9\-_]+)/?"
50 | STEAM_WORKSHOP_URL_PATTERN = r"https://steamcommunity\.com/(sharedfiles/filedetails|workshop/filedetails)/\?id=(\d+)"
51 |
52 | # **🔹 截图路径**
53 | STORE_SCREENSHOT_PATH = "./data/plugins/astrbot_plugin_steamshot/screenshots/store_screenshot.png"
54 | PROFILE_SCREENSHOT_PATH = "./data/plugins/astrbot_plugin_steamshot/screenshots/profile_screenshot.png"
55 | WORKSHOP_SCREENSHOT_PATH = "./data/plugins/astrbot_plugin_steamshot/screenshots/workshop_screenshot.png"
56 |
57 | # **🔹 指定 ChromeDriver 路径**
58 | MANUAL_CHROMEDRIVER_PATH = r""
59 | CHROMEDRIVER_PATH_FILE = "./chromedriver_path.txt"
60 |
61 | def get_stored_chromedriver():
62 | """ 读取本地缓存的 ChromeDriver 路径 """
63 | if os.path.exists(CHROMEDRIVER_PATH_FILE):
64 | with open(CHROMEDRIVER_PATH_FILE, "r") as f:
65 | path = f.read().strip()
66 | if os.path.exists(path):
67 | return path
68 | return None
69 |
70 | def get_chromedriver():
71 | """ 获取 ChromeDriver 路径,优先使用手动路径或缓存路径,若无则下载。
72 | 若已有驱动但版本与当前 Chrome 不符(前三位版本号),则重新下载。
73 | """
74 | def get_browser_version():
75 | try:
76 | if sys.platform.startswith("win"):
77 | key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\\Google\\Chrome\\BLBeacon")
78 | version, _ = winreg.QueryValueEx(key, "version")
79 | return version
80 | elif sys.platform.startswith("linux"):
81 | result = subprocess.run(["google-chrome", "--version"], capture_output=True, text=True)
82 | return result.stdout.strip().split()[-1]
83 | elif sys.platform == "darwin":
84 | result = subprocess.run(["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "--version"], capture_output=True, text=True)
85 | return result.stdout.strip().split()[-1]
86 | except Exception:
87 | return None
88 |
89 | def extract_driver_version_from_path(path):
90 | try:
91 | parts = os.path.normpath(path).split(os.sep)
92 | for part in parts:
93 | if part.count(".") >= 2:
94 | return part # e.g., '137.0.7151.68'
95 | return None
96 | except Exception:
97 | return None
98 |
99 | def versions_match(browser_ver, driver_ver):
100 | try:
101 | b_parts = browser_ver.split(".")[:3]
102 | d_parts = driver_ver.split(".")[:3]
103 | return b_parts == d_parts
104 | except Exception:
105 | return False
106 |
107 | browser_version = get_browser_version()
108 |
109 | if MANUAL_CHROMEDRIVER_PATH and os.path.exists(MANUAL_CHROMEDRIVER_PATH):
110 | driver_version = extract_driver_version_from_path(MANUAL_CHROMEDRIVER_PATH)
111 | print(f"🌐 检测到浏览器版本: {browser_version}, 当前驱动版本: {driver_version}")
112 | if browser_version and driver_version and versions_match(browser_version, driver_version):
113 | print(f"✅ 使用手动指定的 ChromeDriver: {MANUAL_CHROMEDRIVER_PATH}(版本匹配)")
114 | return MANUAL_CHROMEDRIVER_PATH
115 | else:
116 | print("⚠️ 手动指定的 ChromeDriver 版本与浏览器不匹配,忽略使用")
117 |
118 | stored_path = get_stored_chromedriver()
119 | if stored_path and os.path.exists(stored_path):
120 | driver_version = extract_driver_version_from_path(stored_path)
121 | print(f"🌐 检测到浏览器版本: {browser_version}, 当前驱动版本: {driver_version}")
122 | if browser_version and driver_version and versions_match(browser_version, driver_version):
123 | print(f"✅ 使用本地缓存的 ChromeDriver: {stored_path}(版本匹配)")
124 | return stored_path
125 | else:
126 | print("⚠️ 本地 ChromeDriver 版本不匹配(前三位),准备重新下载...")
127 | try:
128 | os.remove(stored_path)
129 | print("🗑 已删除旧的驱动")
130 | except Exception as e:
131 | print(f"❌ 删除旧驱动失败: {e}")
132 |
133 | print("⚠️ 未找到有效的 ChromeDriver 或需重新下载,正在下载最新版本...")
134 | try:
135 | new_driver_path = ChromeDriverManager().install()
136 | with open(CHROMEDRIVER_PATH_FILE, "w") as f:
137 | f.write(new_driver_path)
138 | print(f"✅ 已下载并缓存 ChromeDriver: {new_driver_path}")
139 | return new_driver_path
140 | except Exception as e:
141 | print(f"❌ ChromeDriver 下载失败: {e}")
142 | return None
143 |
144 | CHROMEDRIVER_PATH = get_chromedriver()
145 |
146 | def create_driver(apply_login=True, url=None):
147 | """ 创建 Selenium WebDriver,支持可选的Steam登录 """
148 | options = Options()
149 | options.add_argument("--headless")
150 | options.add_argument("--disable-gpu")
151 | options.add_argument("--no-sandbox")
152 | options.add_argument("--disable-blink-features=AutomationControlled")
153 | options.add_argument("--disable-usb-device-detection")
154 | options.add_argument("--log-level=3")
155 | options.add_argument("--silent")
156 | options.add_experimental_option("excludeSwitches", ["enable-logging", "enable-automation", "disable-usb", "enable-devtools"])
157 |
158 | service = Service(CHROMEDRIVER_PATH)
159 | service.creation_flags = 0x08000000
160 | service.log_output = subprocess.DEVNULL
161 |
162 | driver = webdriver.Chrome(service=service, options=options)
163 |
164 | # 如果启用了登录并且传入了apply_login参数,应用Steam登录cookies
165 | if apply_login:
166 | from .steam_login import apply_cookies_to_driver
167 | # 传入URL参数,让函数根据URL自动选择应用哪个域的cookies
168 | login_applied = apply_cookies_to_driver(driver, url)
169 | if login_applied:
170 | print("✅ 已应用Steam登录信息")
171 |
172 | return driver
173 |
174 | def bypass_steam_age_check(driver):
175 | """
176 | 自动处理 Steam 年龄验证页面和敏感内容验证页面。
177 | 如果当前页面是验证页,自动填写信息并跳转。
178 | """
179 | try:
180 | # 检查当前URL是否包含agecheck关键字
181 | if "agecheck" not in driver.current_url:
182 | return # 不是验证页面,直接返回
183 |
184 | # 检查页面内容判断是哪种验证类型
185 | # 方法1:检查是否存在年龄下拉框(常规年龄验证)
186 | is_age_verification = False
187 | is_content_verification = False
188 |
189 | try:
190 | # 先尝试检测常规年龄验证页面特有元素
191 | if driver.find_elements(By.ID, "ageYear"):
192 | is_age_verification = True
193 | print("🔞 检测到 Steam 年龄验证页面,正在自动跳过...")
194 | # 检测敏感内容验证页面特有元素
195 | elif driver.find_elements(By.ID, "app_agegate") and driver.find_elements(By.ID, "view_product_page_btn"):
196 | is_content_verification = True
197 | print("🔞 检测到 Steam 敏感内容验证页面,正在自动跳过...")
198 | except:
199 | # 如果上述检测失败,尝试通过页面源码判断
200 | page_source = driver.page_source
201 | if "ageYear" in page_source:
202 | is_age_verification = True
203 | print("🔞 检测到 Steam 年龄验证页面,正在自动跳过...")
204 | elif "app_agegate" in page_source and "view_product_page_btn" in page_source:
205 | is_content_verification = True
206 | print("🔞 检测到 Steam 敏感内容验证页面,正在自动跳过...")
207 |
208 | # 保存跳转前的 URL
209 | before_url = driver.current_url
210 |
211 | # 处理常规年龄验证
212 | if is_age_verification:
213 | # 等待出生日期下拉框出现
214 | WebDriverWait(driver, 5).until(EC.presence_of_element_located((By.ID, "ageYear")))
215 |
216 | # 选择出生日期
217 | Select(driver.find_element(By.ID, "ageYear")).select_by_visible_text("2000")
218 |
219 | # 尝试执行 JS 跳转函数
220 | driver.execute_script("ViewProductPage()")
221 |
222 | # 处理敏感内容验证
223 | elif is_content_verification:
224 | # 尝试直接点击"查看页面"按钮
225 | try:
226 | view_btn = WebDriverWait(driver, 5).until(
227 | EC.element_to_be_clickable((By.ID, "view_product_page_btn"))
228 | )
229 | view_btn.click()
230 | except:
231 | # 如果按钮点击失败,尝试执行JS函数
232 | driver.execute_script("ViewProductPage()")
233 |
234 | else:
235 | # 如果无法确定验证类型,但确实在agecheck页面,尝试通用方法
236 | print("⚠️ 未能识别验证类型,尝试通用方法跳转...")
237 | try:
238 | # 尝试执行 JS 跳转函数 (两种验证页面都使用这个函数)
239 | driver.execute_script("ViewProductPage()")
240 | except:
241 | # 尝试点击任何可能的"查看页面"按钮
242 | buttons = driver.find_elements(By.CSS_SELECTOR, ".btnv6_blue_hoverfade")
243 | for button in buttons:
244 | if "查看" in button.text:
245 | button.click()
246 | break
247 |
248 | # 等待 URL 发生变化,表示跳转成功
249 | WebDriverWait(driver, 10).until(EC.url_changes(before_url))
250 | print("✅ 已跳转至游戏页面")
251 |
252 | # 等待游戏页面加载完成 (寻找游戏名称元素)
253 | WebDriverWait(driver, 10).until(
254 | EC.presence_of_element_located((By.CLASS_NAME, "apphub_AppName"))
255 | )
256 |
257 | except Exception as e:
258 | print(f"⚠️ Steam 验证页面跳过失败: {e}")
259 |
260 | async def capture_screenshot(url, save_path):
261 | """ 截取网页完整截图(支持懒加载内容) """
262 | def run():
263 | driver = None
264 | try:
265 | # 修改:传递URL参数以应用正确的cookies
266 | driver = create_driver(apply_login=True, url=url)
267 | driver.set_page_load_timeout(15)
268 |
269 | for attempt in range(3):
270 | try:
271 | driver.get(url)
272 | bypass_steam_age_check(driver)
273 | break
274 | except Exception:
275 | print(f"⚠️ 第 {attempt + 1} 次刷新页面...")
276 | driver.refresh()
277 |
278 | # 等待页面初步加载完成
279 | time.sleep(2)
280 |
281 | # 自动滚动以触发懒加载
282 | last_height = driver.execute_script("return document.body.scrollHeight")
283 | while True:
284 | driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
285 | time.sleep(2) # 等待内容加载,可视页面内容调整
286 | new_height = driver.execute_script("return document.body.scrollHeight")
287 | if new_height == last_height:
288 | break
289 | last_height = new_height
290 |
291 | # 设置窗口为整页高度以便完整截图
292 | driver.set_window_size(1440, last_height)
293 | os.makedirs(os.path.dirname(save_path), exist_ok=True)
294 | driver.save_screenshot(save_path)
295 | print(f"✅ 截图已保存: {save_path}")
296 |
297 | except Exception as e:
298 | print(f"❌ 截图错误: {e}")
299 |
300 | finally:
301 | if driver:
302 | driver.quit()
303 |
304 | await asyncio.to_thread(run)
305 |
306 | async def get_steam_workshop_info(url):
307 | """ 解析 Steam 创意工坊页面信息 """
308 | def parse():
309 | # 传入URL以便应用正确的cookies
310 | driver = create_driver(apply_login=True, url=url)
311 | try:
312 | driver.set_page_load_timeout(15)
313 | for attempt in range(3):
314 | try:
315 | driver.get(url)
316 | bypass_steam_age_check(driver)
317 | break
318 | except Exception:
319 | print(f"⚠️ 第 {attempt + 1} 次刷新页面...")
320 | driver.refresh()
321 |
322 | soup = BeautifulSoup(driver.page_source, "html.parser")
323 |
324 | info = {}
325 |
326 | # 0. 获取游戏名称和链接
327 | breadcrumbs = soup.find("div", class_="breadcrumbs")
328 | if breadcrumbs:
329 | game_link = breadcrumbs.find("a")
330 | if game_link:
331 | info["🎮 所属游戏"] = game_link.text.strip()
332 | game_href = game_link["href"]
333 | if not game_href.startswith("http"):
334 | game_href = "https://steamcommunity.com" + game_href
335 | info["🔗 游戏链接"] = game_href
336 |
337 | # 1. 获取模组名称
338 | title = soup.find("div", class_="workshopItemTitle")
339 | info["🛠️ 模组名称"] = title.text.strip() if title else "未知"
340 |
341 | # 2. 获取作者信息和真实主页链接
342 | creator_block = soup.find("div", class_="creatorsBlock")
343 | if creator_block:
344 | author_name = next((text for text in creator_block.stripped_strings if text.strip()), "未知")
345 | author_link = creator_block.find("a")
346 | if author_link:
347 | info["👤 作者"] = author_name.split('\n')[0].strip()
348 | author_href = author_link["href"]
349 | if not author_href.startswith("http"):
350 | author_href = "https://steamcommunity.com" + author_href
351 | info["🔗 作者主页"] = author_href
352 |
353 | status = creator_block.find("span", class_="friendSmallText")
354 | if status:
355 | info["🟢 作者状态"] = status.text.strip()
356 |
357 | # 3. 获取评分信息
358 | rating_section = soup.find("div", class_="ratingSection")
359 | if rating_section:
360 | rating_img = rating_section.find("img")
361 | if rating_img:
362 | info["⭐ 评分"] = rating_img["src"].split("/")[-1].split("_")[0] + " stars"
363 | num_ratings = rating_section.find("div", class_="numRatings")
364 | if num_ratings:
365 | info["📈 评分数量"] = num_ratings.text.strip()
366 |
367 | # 4. 获取统计数据(访客、订阅、收藏)
368 | stats_table = soup.find("table", class_="stats_table")
369 | if stats_table:
370 | for row in stats_table.find_all("tr"):
371 | cells = row.find_all("td")
372 | if len(cells) == 2:
373 | value = cells[0].text.strip()
374 | label = cells[1].text.strip()
375 |
376 | if "Unique Visitors" in label:
377 | info["👀 访客数"] = value
378 | elif "Current Subscribers" in label:
379 | info["📊 订阅数"] = value
380 | elif "Current Favorites" in label:
381 | info["❤️ 收藏数"] = value
382 |
383 | # 5. 获取文件大小和日期信息
384 | stats_right = soup.find("div", class_="detailsStatsContainerRight")
385 | if stats_right:
386 | stats_items = stats_right.find_all("div", class_="detailsStatRight")
387 | if len(stats_items) >= 1:
388 | info["📦 文件大小"] = stats_items[0].text.strip()
389 | if len(stats_items) >= 2:
390 | info["🗓️ 创建日期"] = stats_items[1].text.strip()
391 | if len(stats_items) >= 3:
392 | info["🔄 更新日期"] = stats_items[2].text.strip()
393 |
394 | # 6. 获取标签信息
395 | tags_container = soup.find("div", class_="rightDetailsBlock")
396 | if tags_container:
397 | tags = []
398 | for tag_div in tags_container.find_all("div", class_="workshopTags"):
399 | tag_title = tag_div.find("span", class_="workshopTagsTitle")
400 | if tag_title:
401 | tag_text = tag_title.text.replace(":", "").strip()
402 | tag_links = [a.text for a in tag_div.find_all("a")]
403 | if tag_links:
404 | tags.append(f"{tag_text}: {', '.join(tag_links)}")
405 | if tags:
406 | info["🏷️ 标签"] = "\n".join(tags)
407 |
408 | # 7. 获取描述内容
409 | description = soup.find("div", class_="workshopItemDescription")
410 | if description:
411 | for tag in description.find_all(["script", "style", "img", "a"]):
412 | tag.decompose()
413 | desc_text = description.get_text(separator="\n", strip=True)
414 | info["📝 描述"] = desc_text[:500] + "..." if len(desc_text) > 500 else desc_text
415 |
416 | return info
417 |
418 | finally:
419 | driver.quit()
420 |
421 | return await asyncio.to_thread(parse)
422 |
423 |
424 | async def process_steam_workshop(event, workshop_url):
425 | """ 处理 Steam 创意工坊链接 """
426 | result = MessageChain()
427 |
428 | info_task = asyncio.create_task(get_steam_workshop_info(workshop_url))
429 | screenshot_task = asyncio.create_task(capture_screenshot(workshop_url, WORKSHOP_SCREENSHOT_PATH))
430 |
431 | await asyncio.gather(info_task, screenshot_task)
432 | workshop_info = await info_task
433 |
434 | formatted_info = []
435 |
436 | # 优先显示游戏信息
437 | if "🎮 所属游戏" in workshop_info:
438 | game_info = f"游戏名称: {workshop_info['🎮 所属游戏']}"
439 | if "🔗 游戏链接" in workshop_info:
440 | game_info += f" {workshop_info['🔗 游戏链接']}"
441 | formatted_info.append(game_info)
442 | formatted_info.append("")
443 |
444 | # 添加其他信息
445 | for key, value in workshop_info.items():
446 | if key not in ["🎮 所属游戏", "🔗 游戏链接"]:
447 | if key in ["🔗 作者主页", "🖼️ 预览图"]:
448 | formatted_info.append(f"{key}: {value}")
449 | elif key == "🏷️ 标签":
450 | formatted_info.append(f"{key}:")
451 | formatted_info.append(value)
452 | else:
453 | formatted_info.append(f"{key}: {value}")
454 |
455 | if formatted_info:
456 | result.chain.append(Plain("\n".join(formatted_info)))
457 |
458 | if os.path.exists(WORKSHOP_SCREENSHOT_PATH):
459 | result.chain.append(Image.fromFileSystem(WORKSHOP_SCREENSHOT_PATH))
460 |
461 | await event.send(result)
462 |
463 | async def get_steam_page_info(url):
464 | """ 解析 Steam 商店页面信息 """
465 | def parse():
466 | # 导入Tag类用于类型检查
467 | from bs4.element import Tag
468 |
469 | # 传入URL以便应用正确的cookies
470 | driver = create_driver(apply_login=True, url=url)
471 | if not driver:
472 | return []
473 | try:
474 | driver.set_page_load_timeout(15)
475 | for attempt in range(3):
476 | try:
477 | driver.get(url)
478 | bypass_steam_age_check(driver)
479 | break
480 | except Exception:
481 | print(f"⚠️ 第 {attempt + 1} 次刷新页面...")
482 | driver.refresh()
483 |
484 | soup = BeautifulSoup(driver.page_source, "html.parser")
485 |
486 | game_name = soup.find("div", class_="apphub_AppName")
487 | game_name = game_name.text.strip() if game_name else "未知"
488 |
489 | release_date = soup.find("div", class_="date")
490 | release_date = release_date.text.strip() if release_date else "未知"
491 |
492 | developers = [a.text.strip() for a in soup.select("div#developers_list a")]
493 | developers = ", ".join(developers) if developers else "未知"
494 |
495 | publisher_div = soup.find("div", class_="dev_row")
496 | publisher = "未知"
497 | if publisher_div:
498 | next_div = publisher_div.find_next_sibling("div")
499 | if next_div:
500 | # **🔥 直接获取纯文本,并去掉前缀 "发行商:"**
501 | publisher = next_div.get_text(strip=True).replace("发行商:", "").strip()
502 |
503 | tags = soup.select("a.app_tag")
504 | tags = ",".join([tag.text.strip() for tag in tags[:5]]) if tags else "未知"
505 |
506 | description_div = soup.find("div", class_="game_description_snippet")
507 | description = description_div.text.strip() if description_div else "暂无简介"
508 |
509 | review_summary = soup.find("span", class_="game_review_summary")
510 | review_summary = review_summary.text.strip() if review_summary else "暂无评分"
511 |
512 | # 修改价格解析逻辑
513 | price_items = []
514 |
515 | # 检查是否为预购游戏
516 | is_preorder = False
517 | preorder_date = None
518 | coming_soon_div = soup.find("div", class_="game_area_comingsoon")
519 | if coming_soon_div:
520 | is_preorder = True
521 | coming_soon_h1 = coming_soon_div.find("h1")
522 | if coming_soon_h1:
523 | preorder_date = coming_soon_h1.text.strip()
524 | print(f"✅ 检测到预购游戏: {preorder_date}")
525 |
526 | # 检查是否为免费游戏
527 | is_free_game = False
528 | free_tag = soup.find("div", class_="game_purchase_price", string=lambda s: s and ("免费" in s or "free" in s.lower()))
529 | if free_tag:
530 | is_free_game = True
531 | print("✅ 检测到免费游戏")
532 |
533 | try:
534 | # 根据游戏类型选择不同的处理逻辑
535 | if is_free_game:
536 | price_items.append("免费游戏")
537 | elif is_preorder: # 添加这个条件分支处理预购游戏
538 | print("🔍 尝试提取预购游戏的价格信息")
539 |
540 | # 专门处理预购游戏的价格提取
541 | purchase_area = soup.find("div", id="game_area_purchase")
542 | if purchase_area:
543 | print("✅ 找到预购游戏购买区域")
544 |
545 | # 1. 查找所有可能的预购选项容器
546 | preorder_containers = []
547 |
548 | # 搜索所有可能的预购容器类型
549 | # 只使用顶层容器,避免重复选择
550 | for container in purchase_area.select(".game_area_purchase_game_wrapper"):
551 | preorder_containers.append(container)
552 |
553 | # 如果找不到上面的容器,尝试其他选择器
554 | if not preorder_containers:
555 | for container in purchase_area.select(".game_area_purchase_game"):
556 | # 确保这不是某个已选择容器的子元素
557 | if not any(c.find(container) for c in preorder_containers):
558 | preorder_containers.append(container)
559 |
560 | # 最后,如果仍然找不到,尝试从版本选项容器中查找
561 | if not preorder_containers:
562 | for container in purchase_area.select(".game_purchase_options_editions_container > div"):
563 | if container.select_one("h2.title, h1.title") is not None:
564 | preorder_containers.append(container)
565 |
566 | # 去重处理 - 使用URL或标题作为唯一标识
567 | unique_titles = set()
568 | filtered_containers = []
569 |
570 | for container in preorder_containers:
571 | title_elem = container.select_one("h1.title, h2.title")
572 | if title_elem:
573 | title = title_elem.text.strip()
574 | if title not in unique_titles:
575 | unique_titles.add(title)
576 | filtered_containers.append(container)
577 |
578 | preorder_containers = filtered_containers
579 |
580 | print(f"✅ 找到 {len(preorder_containers)} 个唯一预购选项容器")
581 |
582 | if not preorder_containers:
583 | # 如果没有找到标准容器,尝试直接从purchase_area获取信息
584 | print("⚠️ 没有找到标准预购容器,尝试直接分析")
585 |
586 | # 从购买区域直接提取价格信息
587 | price_elems = purchase_area.select(".game_purchase_price, .discount_final_price")
588 | for price_elem in price_elems:
589 | price_text = price_elem.text.strip()
590 | if price_text:
591 | preorder_title = f"预购 {game_name}"
592 | if preorder_date:
593 | preorder_title += f" ({preorder_date})"
594 |
595 | # 检查是否有折扣
596 | parent = price_elem.parent
597 | discount_pct = None
598 | if parent:
599 | discount_elem = parent.select_one(".discount_pct")
600 | if discount_elem:
601 | discount_pct = discount_elem.text.strip()
602 |
603 | if discount_pct:
604 | formatted_price = f"{preorder_title} {discount_pct} {price_text}"
605 | else:
606 | formatted_price = f"{preorder_title} {price_text}"
607 |
608 | print(f"💲 预购价格: {formatted_price}")
609 | price_items.append(formatted_price)
610 | else:
611 | # 处理找到的预购容器
612 | for i, container in enumerate(preorder_containers):
613 | try:
614 | # 尝试查找标题
615 | title_elem = container.select_one("h1.title, h2.title, .game_purchase_options_editions_header_title")
616 | title = title_elem.text.strip() if title_elem else f"预购 {game_name}"
617 |
618 | # 确保标题包含"预购"字样
619 | if "预购" not in title:
620 | title = f"预购 {title}"
621 |
622 | # 如果有预购日期,添加到标题
623 | if preorder_date and preorder_date not in title:
624 | title += f" ({preorder_date})"
625 |
626 | # 查找价格元素
627 | price_elem = container.select_one(".game_purchase_price, .discount_final_price")
628 | if price_elem:
629 | price_text = price_elem.text.strip()
630 |
631 | # 检查是否有折扣
632 | discount_elem = container.select_one(".discount_pct")
633 | if discount_elem:
634 | discount_text = discount_elem.text.strip()
635 | formatted_price = f"{title} {discount_text} {price_text}"
636 | else:
637 | formatted_price = f"{title} {price_text}"
638 |
639 | print(f"💲 预购价格选项 {i+1}: {formatted_price}")
640 | price_items.append(formatted_price)
641 | else:
642 | # 如果没有找到价格,至少显示预购信息
643 | price_items.append(f"{title} 价格未知")
644 | except Exception as e:
645 | print(f"❌ 处理预购选项 {i+1} 时出错: {e}")
646 |
647 | # 如果所有方法都失败,至少显示它是预购游戏
648 | if not price_items:
649 | preorder_info = f"预购 {game_name}"
650 | if preorder_date:
651 | preorder_info += f" ({preorder_date})"
652 | price_items.append(f"{preorder_info} 价格未知")
653 | else:
654 | # 如果没有购买区域,添加基本预购信息
655 | preorder_info = f"预购 {game_name}"
656 | if preorder_date:
657 | preorder_info += f" ({preorder_date})"
658 | price_items.append(preorder_info)
659 | else:
660 | # 找到游戏购买区域
661 | purchase_area = soup.find("div", id="game_area_purchase")
662 | if purchase_area:
663 | print("✅ 找到游戏购买区域")
664 |
665 | # 获取所有购买选项包装器,但排除DLC部分
666 | purchase_wrappers = []
667 |
668 | for child in purchase_area.children:
669 | if not isinstance(child, Tag):
670 | continue
671 |
672 | # 一旦遇到DLC部分,停止收集
673 | if child.get("id") == "gameAreaDLCSection":
674 | print("✅ 找到DLC部分,停止收集购买选项")
675 | break
676 |
677 | if "game_area_purchase_game_wrapper" in child.get("class", []):
678 | purchase_wrappers.append(child)
679 |
680 | print(f"✅ 找到 {len(purchase_wrappers)} 个购买选项")
681 |
682 | # 处理每个购买选项
683 | for i, wrapper in enumerate(purchase_wrappers):
684 | try:
685 | # 跳过下拉框部分
686 | if wrapper.find("div", class_="game_purchase_sub_dropdown"):
687 | print(f"⏩ 跳过第 {i+1} 个购买选项,因为它是下拉框")
688 | continue
689 |
690 | # 处理动态捆绑包
691 | if "dynamic_bundle_description" in wrapper.get("class", []):
692 | print(f"🔍 第 {i+1} 个购买选项是动态捆绑包")
693 |
694 | # 查找捆绑包标题
695 | bundle_title_elem = wrapper.find("h2", class_="title")
696 | if not bundle_title_elem:
697 | print(f"⚠️ 第 {i+1} 个捆绑包没有找到标题元素")
698 | continue
699 |
700 | # 清理捆绑包标题,移除多余文本
701 | bundle_title = bundle_title_elem.get_text(strip=True)
702 | if bundle_title.startswith("购买 "):
703 | bundle_title = bundle_title[3:]
704 |
705 | # 移除可能的"(?)"符号
706 | bundle_title = bundle_title.replace("(?)", "").strip()
707 |
708 | print(f"📦 捆绑包标题: {bundle_title}")
709 |
710 | # 检查是否已完成合集
711 | collection_complete = wrapper.find("span", class_="collectionComplete")
712 | if collection_complete:
713 | print(f"✓ 捆绑包 \"{bundle_title}\" 已完成合集")
714 | price_items.append(f"{bundle_title} 已完成合集")
715 | continue
716 |
717 | # 获取折扣和价格
718 | discount_block = wrapper.find("div", class_="discount_block")
719 | if discount_block:
720 | discount_pct = discount_block.find("div", class_="bundle_base_discount")
721 | final_price = discount_block.find("div", class_="discount_final_price")
722 |
723 | if discount_pct and final_price:
724 | # 清理价格文本,确保格式正确
725 | discount_text = discount_pct.text.strip()
726 | price_text = final_price.text.strip()
727 | # 如果价格文本包含"您的价格:",只保留价格部分
728 | if "您的价格:" in price_text:
729 | price_parts = price_text.split("您的价格:")
730 | price_text = price_parts[-1].strip()
731 |
732 | formatted_price = f"{bundle_title} {discount_text} {price_text}"
733 | print(f"💲 捆绑包价格: {formatted_price}")
734 | price_items.append(formatted_price)
735 | elif final_price:
736 | price_text = final_price.text.strip()
737 | # 如果价格文本包含"您的价格:",只保留价格部分
738 | if "您的价格:" in price_text:
739 | price_parts = price_text.split("您的价格:")
740 | price_text = price_parts[-1].strip()
741 |
742 | formatted_price = f"{bundle_title} {price_text}"
743 | print(f"💲 捆绑包价格: {formatted_price}")
744 | price_items.append(formatted_price)
745 |
746 | continue
747 |
748 | # 处理普通游戏购买选项
749 | print(f"🔍 第 {i+1} 个购买选项是普通游戏")
750 |
751 | game_purchase = wrapper.find("div", class_="game_area_purchase_game")
752 | if not game_purchase:
753 | print(f"⚠️ 第 {i+1} 个购买选项没有找到game_area_purchase_game元素")
754 | continue
755 |
756 | title_elem = game_purchase.find("h2", class_="title")
757 | if not title_elem:
758 | print(f"⚠️ 第 {i+1} 个购买选项没有找到标题元素")
759 | continue
760 |
761 | title = title_elem.text.strip()
762 | if title.startswith("购买 "):
763 | title = title[3:]
764 |
765 | print(f"🎮 游戏标题: {title}")
766 |
767 | # 检查是否在库中
768 | in_library = game_purchase.find("div", class_="package_in_library_flag")
769 |
770 | if in_library:
771 | print(f"✓ 游戏 \"{title}\" 已在库中")
772 | price_items.append(f"{title} 在库中")
773 | continue
774 |
775 | # 获取价格信息
776 | discount_block = game_purchase.find("div", class_="discount_block")
777 | regular_price = game_purchase.find("div", class_="game_purchase_price")
778 |
779 | if discount_block:
780 | discount_pct = discount_block.find("div", class_="discount_pct")
781 | final_price = discount_block.find("div", class_="discount_final_price")
782 |
783 | if discount_pct and final_price:
784 | price_text = f"{title} {discount_pct.text.strip()} {final_price.text.strip()}"
785 | print(f"💲 折扣价格: {price_text}")
786 | price_items.append(price_text)
787 | elif final_price:
788 | price_text = f"{title} {final_price.text.strip()}"
789 | print(f"💲 最终价格: {price_text}")
790 | price_items.append(price_text)
791 | elif regular_price:
792 | price_text = f"{title} {regular_price.text.strip()}"
793 | print(f"💲 常规价格: {price_text}")
794 | price_items.append(price_text)
795 | else:
796 | print(f"⚠️ 游戏 \"{title}\" 没有找到价格信息")
797 | price_items.append(f"{title} 价格未知")
798 | except Exception as e:
799 | print(f"❌ 处理第 {i+1} 个购买选项时出错: {e}")
800 | continue
801 | else:
802 | print("⚠️ 没有找到游戏购买区域")
803 | except Exception as e:
804 | print(f"❌ 解析价格信息时出错: {e}")
805 |
806 | # 格式化价格信息
807 | price_text = "\n".join(price_items) if price_items else "暂无售价"
808 |
809 | return {
810 | "🎮 游戏名称": game_name,
811 | "📅 发行时间": release_date,
812 | "🏗 开发商": developers,
813 | "🏛 发行商": publisher,
814 | "🎭 游戏类别": tags,
815 | "📜 简介": description,
816 | "⭐ 评分": review_summary,
817 | "💰 价格": f"\n{price_text}"
818 | }
819 |
820 | finally:
821 | driver.quit()
822 |
823 | return await asyncio.to_thread(parse)
824 |
825 | async def process_steam_store(event, steam_url):
826 | """ 处理 Steam 商店信息 """
827 | result = MessageChain()
828 | screenshot_task = asyncio.create_task(capture_screenshot(steam_url, STORE_SCREENSHOT_PATH))
829 | info_task = asyncio.create_task(get_steam_page_info(steam_url))
830 |
831 | await asyncio.gather(screenshot_task, info_task)
832 |
833 | game_info = await info_task
834 | info_text = "\n".join([f"{key}: {value}" for key, value in game_info.items()])
835 |
836 | result.chain.append(Plain(info_text))
837 |
838 | if os.path.exists(STORE_SCREENSHOT_PATH):
839 | result.chain.append(Image.fromFileSystem(STORE_SCREENSHOT_PATH))
840 |
841 | await event.send(result)
842 |
843 | async def get_steam_profile_info(url):
844 | """ 解析 Steam 个人主页信息(支持完整最新动态) """
845 | def parse():
846 | # 传入URL以便应用正确的cookies
847 | driver = create_driver(apply_login=True, url=url)
848 | if not driver:
849 | return []
850 |
851 | standard_profile_lines = []
852 | recent_activity_parsed_lines = []
853 |
854 | try:
855 | driver.set_page_load_timeout(15)
856 | driver.get(url)
857 | time.sleep(2)
858 |
859 | soup = BeautifulSoup(driver.page_source, "html.parser")
860 |
861 | # 1. Steam ID
862 | name_span = soup.find("span", class_="actual_persona_name")
863 | if name_span:
864 | steam_id = name_span.text.strip()
865 | standard_profile_lines.append(f"steam id: {steam_id}")
866 |
867 | # 🔒 1.5 检查封禁状态(如有则立即返回封禁信息)
868 | ban_section = soup.find("div", class_="profile_ban_status")
869 | if ban_section:
870 | ban_records = []
871 | for div in ban_section.find_all("div", class_="profile_ban"):
872 | ban_text = div.get_text(strip=True).replace("|信息", "").strip()
873 | if ban_text:
874 | ban_records.append(ban_text)
875 | # 提取封禁时间(如有)
876 | ban_status_text = ban_section.get_text(separator="\n", strip=True)
877 | for line in ban_status_text.split("\n"):
878 | if "封禁于" in line:
879 | ban_records.append(line.strip())
880 | if ban_records:
881 | standard_profile_lines.append(f"🚫 封禁纪录: \n" + "\n".join(ban_records))
882 |
883 | # 2. 私密资料判断
884 | is_private = False
885 | if soup.find("div", class_="profile_private_info"):
886 | standard_profile_lines.append("此个人资料是私密的")
887 | is_private = True
888 |
889 | # 3. 简介
890 | if not is_private:
891 | summary_div = soup.find("div", class_="profile_summary")
892 | if summary_div:
893 | for tag in summary_div.find_all(["img"]):
894 | tag.decompose()
895 | profile_text = summary_div.get_text(separator="\n", strip=True)
896 | if profile_text:
897 | standard_profile_lines.append(f"个人简介: \n{profile_text}")
898 |
899 | # 4. 等级
900 | level_span = soup.find("span", class_="friendPlayerLevelNum")
901 | if level_span:
902 | standard_profile_lines.append(f"steam等级: {level_span.text.strip()}")
903 |
904 | # 5. 地区
905 | location_div = soup.find("div", class_="header_location")
906 | if location_div:
907 | standard_profile_lines.append(f"地区: {location_div.get_text(strip=True)}")
908 |
909 | # 6. 当前状态
910 | status_div = soup.find("div", class_="responsive_status_info")
911 | if status_div:
912 | header = status_div.find("div", class_="profile_in_game_header")
913 | if header:
914 | state = header.text.strip()
915 | if state == "当前离线":
916 | standard_profile_lines.append("当前状态: 当前离线")
917 | elif state == "当前在线":
918 | standard_profile_lines.append("当前状态: 当前在线")
919 | elif state == "当前正在游戏":
920 | game_name_div = status_div.find("div", class_="profile_in_game_name")
921 | game_name = game_name_div.text.strip() if game_name_div else "未知游戏"
922 | standard_profile_lines.append(f"当前状态: 当前正在游戏 \n {game_name}")
923 |
924 | # 7. 游戏数
925 | for link in soup.find_all("a", href=True):
926 | if "games/?tab=all" in link["href"]:
927 | count_span = link.find("span", class_="profile_count_link_total")
928 | if count_span:
929 | standard_profile_lines.append(f"游戏数: {count_span.text.strip()}")
930 | break
931 |
932 | # 8. 好友数
933 | for link in soup.find_all("a", href=True):
934 | if link["href"].endswith("/friends/"):
935 | count_span = link.find("span", class_="profile_count_link_total")
936 | if count_span:
937 | standard_profile_lines.append(f"好友数: {count_span.text.strip()}")
938 | break
939 |
940 | # 9. 最新动态
941 | if not is_private:
942 | recent_activity_customization_div = None
943 | customization_divs = soup.find_all("div", class_="profile_customization")
944 | for div_block in customization_divs:
945 | header = div_block.find("div", class_="profile_recentgame_header")
946 | if header and "最新动态" in header.get_text(strip=True):
947 | recent_activity_customization_div = div_block
948 | break
949 |
950 | if recent_activity_customization_div:
951 | playtime_header = recent_activity_customization_div.find("div", class_="profile_recentgame_header")
952 | if playtime_header:
953 | playtime_recent_div = playtime_header.find("div", class_="recentgame_recentplaytime")
954 | if playtime_recent_div:
955 | playtime_text_container = playtime_recent_div.find("div")
956 | if playtime_text_container:
957 | playtime = playtime_text_container.text.strip()
958 | if playtime:
959 | recent_activity_parsed_lines.append(f"🕒 最新动态: {playtime}")
960 |
961 | recent_games_block = recent_activity_customization_div.find("div", class_="recent_games")
962 | if recent_games_block:
963 | for game_div in recent_games_block.find_all("div", class_="recent_game", limit=3):
964 | game_name_tag = game_div.find("div", class_="game_name")
965 | game_name = game_name_tag.find("a", class_="whiteLink").text.strip() if game_name_tag and game_name_tag.find("a") else "未知游戏"
966 |
967 | game_info_details_div = game_div.find("div", class_="game_info_details")
968 | total_playtime = "未知总时数"
969 | last_played = None
970 | is_currently_playing = False
971 |
972 | if game_info_details_div:
973 | details_texts = [item.strip() for item in game_info_details_div.contents if isinstance(item, str) and item.strip()]
974 | for part in details_texts:
975 | if part.startswith("总时数"):
976 | total_playtime = part
977 | elif part.startswith("最后运行日期:"):
978 | last_played = part
979 | elif part == "当前正在游戏":
980 | is_currently_playing = True
981 |
982 | recent_activity_parsed_lines.append(f"\n🎮 {game_name}: {total_playtime}")
983 | if is_currently_playing:
984 | recent_activity_parsed_lines.append(f"🎮 当前正在游戏")
985 | elif last_played:
986 | recent_activity_parsed_lines.append(f"📅 {last_played}")
987 |
988 | ach_str = None
989 | stats_div = game_div.find("div", class_="game_info_stats")
990 | if stats_div:
991 | ach_area = stats_div.find("div", class_="game_info_achievements_summary_area")
992 | if ach_area:
993 | summary_span = ach_area.find("span", class_="game_info_achievement_summary")
994 | if summary_span:
995 | ach_text_tag = summary_span.find("a", class_="whiteLink")
996 | ach_progress_tag = summary_span.find("span", class_="ellipsis")
997 | if ach_text_tag and "成就进度" in ach_text_tag.text and ach_progress_tag:
998 | ach_str = f"🏆 {ach_text_tag.text.strip()} {ach_progress_tag.text.strip()}"
999 | if ach_str:
1000 | recent_activity_parsed_lines.append(f"{ach_str}")
1001 |
1002 | return standard_profile_lines + recent_activity_parsed_lines
1003 |
1004 | except Exception as e:
1005 | print(f"❌ 解析 Steam 个人主页错误: {e}")
1006 | combined_on_error = standard_profile_lines + recent_activity_parsed_lines
1007 | return combined_on_error if combined_on_error else ["⚠️ 无法获取个人主页部分信息。"]
1008 |
1009 | finally:
1010 | if driver:
1011 | driver.quit()
1012 |
1013 | return await asyncio.to_thread(parse)
1014 |
1015 |
1016 | async def process_steam_profile(event, profile_url):
1017 | """ 处理 Steam 个人主页 """
1018 | result = MessageChain()
1019 |
1020 | info_task = asyncio.create_task(get_steam_profile_info(profile_url))
1021 | screenshot_task = asyncio.create_task(capture_screenshot(profile_url, PROFILE_SCREENSHOT_PATH))
1022 |
1023 | await asyncio.gather(info_task, screenshot_task)
1024 | profile_info = await info_task
1025 |
1026 | # 表情映射
1027 | emoji_map = {
1028 | "steam id": "🆔",
1029 | "个人简介": "📝",
1030 | "steam等级": "🎖",
1031 | "地区": "📍",
1032 | "当前状态: 当前在线": "🟢",
1033 | "当前状态: 当前离线": "🔴",
1034 | "当前状态: 当前正在游戏": "🎮",
1035 | "游戏数": "🎮",
1036 | "好友数": "👥",
1037 | "此个人资料是私密的": "🔒"
1038 | }
1039 |
1040 | formatted_lines = []
1041 | for line in profile_info:
1042 | key = line.split(":")[0].strip()
1043 | matched_emoji = None
1044 |
1045 | for k, emoji in emoji_map.items():
1046 | if line.startswith(k) or k in line:
1047 | matched_emoji = emoji
1048 | break
1049 |
1050 | if matched_emoji:
1051 | formatted_lines.append(f"{matched_emoji} {line}")
1052 | else:
1053 | formatted_lines.append(line)
1054 |
1055 | if formatted_lines:
1056 | result.chain.append(Plain("\n".join(formatted_lines)))
1057 |
1058 | if os.path.exists(PROFILE_SCREENSHOT_PATH):
1059 | result.chain.append(Image.fromFileSystem(PROFILE_SCREENSHOT_PATH))
1060 |
1061 | await event.send(result)
1062 |
1063 | def verify_steam_login(driver):
1064 | """
1065 | 验证Steam登录状态是否有效
1066 | 参数:
1067 | - driver: Selenium WebDriver实例
1068 | 返回:
1069 | - (bool, str): 登录状态和用户名(如有)
1070 | """
1071 | try:
1072 | # 访问Steam首页
1073 | driver.get("https://store.steampowered.com/")
1074 | time.sleep(2)
1075 |
1076 | # 检查登录状态 - 查找顶部导航栏中的账户名元素
1077 | account_menu = driver.find_element(By.ID, "account_pulldown")
1078 | if account_menu:
1079 | username = account_menu.text.strip()
1080 | if username and username != "登录" and username != "Sign In":
1081 | return True, username
1082 |
1083 | # 尝试其他方法 - 查找账户下拉菜单中是否有"查看个人资料"链接
1084 | try:
1085 | profile_link = driver.find_element(By.XPATH, "//a[contains(@href, '/profiles/') or contains(@href, '/id/')]")
1086 | if profile_link:
1087 | return True, "已登录 (未获取到用户名)"
1088 | except:
1089 | pass
1090 |
1091 | return False, "未登录"
1092 | except Exception as e:
1093 | print(f"❌ 验证Steam登录状态失败: {e}")
1094 | return False, f"验证失败: {str(e)}"
1095 |
1096 | async def test_steam_login():
1097 | """测试Steam登录状态"""
1098 | driver = None
1099 | try:
1100 | driver = create_driver(apply_login=True)
1101 | login_status, username = verify_steam_login(driver)
1102 |
1103 | if login_status:
1104 | return f"✅ Steam登录成功! 用户名: {username}"
1105 | else:
1106 | return f"❌ Steam登录失败: {username}"
1107 | except Exception as e:
1108 | return f"❌ 测试Steam登录出错: {e}"
1109 | finally:
1110 | if driver:
1111 | driver.quit()
1112 |
1113 | @register("astrbot_plugin_steamshot", "Inori-3333", "检测 Steam 链接,截图并返回游戏信息", "1.8.5", "https://github.com/inori-3333/astrbot_plugin_steamshot")
1114 | class SteamPlugin(Star):
1115 |
1116 | # 定义 HTML 模板
1117 | HTML_STORE_TEMPLATE = """
1118 |
1119 |
1120 |
1121 |
1122 |
1190 |
1191 |
1192 |
1193 | {% for game in games %}
1194 |
1195 |
{{ loop.index }}
1196 |
1216 |
1217 | {% endfor %}
1218 |
请在30秒内回复对应游戏的序号,否则将默认访问第一个游戏
1219 |
1220 |
1221 |
1222 | """
1223 |
1224 | HTML_USER_TEMPLATE = """
1225 |
1226 |
1227 |
1228 |
1229 |
1282 |
1283 |
1284 |
1285 | {% for user in users %}
1286 |
1287 |
{{ loop.index }}
1288 | {% if user.avatar_url %}
1289 |

1290 | {% endif %}
1291 |
1292 |
{{ user.name }}
1293 |
1294 | {% if user.location %}
1295 |
(别名/)地区: {{ user.location }}
1296 | {% endif %}
1297 | {% if user.custom_url %}
1298 |
自定义URL: {{ user.custom_url }}
1299 | {% endif %}
1300 |
1301 |
1302 |
1303 | {% endfor %}
1304 |
请在30秒内回复对应用户的序号,否则将默认访问第一个用户
1305 |
1306 |
1307 |
1308 | """
1309 |
1310 | def __init__(self, context: Context, config=None):
1311 | super().__init__(context)
1312 | # 初始化配置
1313 | self.config = config or {}
1314 |
1315 | # 从配置中读取Steam登录设置
1316 | self.enable_steam_login = self.config.get("enable_steam_login", False)
1317 | self.steam_store_cookies = self.config.get("steam_store_cookies", "")
1318 | self.steam_community_cookies = self.config.get("steam_community_cookies", "")
1319 |
1320 | # 应用配置
1321 | self._apply_config()
1322 |
1323 | def _apply_config(self):
1324 | """应用配置到插件功能"""
1325 | from .steam_login import enable_steam_login, disable_steam_login, save_steam_cookies
1326 |
1327 | if self.enable_steam_login:
1328 | # 应用Steam商店cookies
1329 | if self.steam_store_cookies:
1330 | save_steam_cookies(self.steam_store_cookies, "store")
1331 |
1332 | # 应用Steam社区cookies
1333 | if self.steam_community_cookies:
1334 | save_steam_cookies(self.steam_community_cookies, "community")
1335 |
1336 | # 启用Steam登录
1337 | enable_steam_login()
1338 | else:
1339 | # 禁用Steam登录
1340 | disable_steam_login()
1341 |
1342 | @filter.regex(STEAM_URL_PATTERN)
1343 | async def handle_steam_store(self, event: AstrMessageEvent):
1344 | steam_url = re.search(STEAM_URL_PATTERN, event.message_str).group(0)
1345 | await process_steam_store(event, steam_url)
1346 |
1347 | @filter.regex(STEAM_PROFILE_URL_PATTERN)
1348 | async def handle_steam_profile(self, event: AstrMessageEvent):
1349 | profile_url = re.search(STEAM_PROFILE_URL_PATTERN, event.message_str).group(0)
1350 | await process_steam_profile(event, profile_url)
1351 |
1352 | @filter.regex(STEAM_WORKSHOP_URL_PATTERN)
1353 | async def handle_steam_workshop(self, event: AstrMessageEvent):
1354 | match = re.search(STEAM_WORKSHOP_URL_PATTERN, event.message_str)
1355 | workshop_id = match.group(2)
1356 | workshop_url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}"
1357 | await process_steam_workshop(event, workshop_url)
1358 |
1359 | async def steam_store_search(self, search_game_name: str, event: AstrMessageEvent):
1360 | """搜索 Steam 商店并返回前10个结果"""
1361 | user_id = event.get_sender_id()
1362 |
1363 | # 检查用户是否已经有搜索会话
1364 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "store_search":
1365 | yield event.plain_result("您有一个正在进行的搜索会话,请先完成或等待会话超时。")
1366 | return
1367 |
1368 | yield event.plain_result(f"🔍 正在搜索游戏: {search_game_name}...")
1369 |
1370 | try:
1371 | # 使用登录状态搜索
1372 | login_driver = create_driver(apply_login=True, url="https://store.steampowered.com/")
1373 | url = f"https://store.steampowered.com/search/?term={search_game_name}&ndl=1"
1374 | game_results = []
1375 |
1376 | try:
1377 | login_driver.get(url)
1378 | time.sleep(2)
1379 |
1380 | soup = BeautifulSoup(login_driver.page_source, "html.parser")
1381 |
1382 | # 检查是否有结果
1383 | no_result_div = soup.select_one("#search_results .search_results_count")
1384 | if no_result_div and "0 个匹配的搜索结果" in no_result_div.text:
1385 | yield event.plain_result(f"❌ 没有找到名为 {search_game_name} 的游戏。")
1386 | return
1387 |
1388 | # 获取搜索结果
1389 | result_containers = soup.select("#search_resultsRows a")
1390 |
1391 | if not result_containers:
1392 | yield event.plain_result("⚠️ 未找到搜索结果。")
1393 | return
1394 |
1395 | # 限制为前10个结果
1396 | result_containers = result_containers[:10]
1397 |
1398 | # 在for循环中修改价格提取部分
1399 | for i, container in enumerate(result_containers, 1):
1400 | try:
1401 | game_url = container["href"]
1402 | title = container.select_one(".title").text.strip() if container.select_one(".title") else "未知标题"
1403 |
1404 | # 获取封面图片
1405 | image_elem = container.select_one(".search_capsule img")
1406 | image_url = image_elem["src"] if image_elem else None
1407 |
1408 | # 获取发布日期
1409 | release_date = container.select_one(".search_released")
1410 | release_date = release_date.text.strip() if release_date else "未知"
1411 |
1412 | # 改进价格提取逻辑
1413 | price = "未知"
1414 |
1415 | # 直接获取价格容器
1416 | price_container = container.select_one(".search_price_discount_combined")
1417 | if price_container:
1418 | # 检查游戏是否免费
1419 | if price_container.get("data-price-final") == "0":
1420 | price = "免费游戏"
1421 | else:
1422 | # 检查是否有折扣区块
1423 | discount_block = price_container.select_one(".discount_block")
1424 | if discount_block:
1425 | # 判断是否有折扣
1426 | has_discount = "no_discount" not in discount_block.get("class", [])
1427 |
1428 | if has_discount:
1429 | # 获取折扣百分比
1430 | discount_pct_elem = discount_block.select_one(".discount_pct")
1431 | discount_pct = discount_pct_elem.text.strip() if discount_pct_elem else ""
1432 |
1433 | # 获取折扣后价格
1434 | final_price_elem = discount_block.select_one(".discount_final_price")
1435 | final_price = final_price_elem.text.strip() if final_price_elem else ""
1436 |
1437 | # 获取原价
1438 | original_price_elem = discount_block.select_one(".discount_original_price")
1439 | original_price = original_price_elem.text.strip() if original_price_elem else ""
1440 |
1441 | # 组合价格信息
1442 | if discount_pct and final_price:
1443 | price = f"{discount_pct} {final_price}"
1444 | elif final_price:
1445 | price = final_price
1446 | else:
1447 | # 无折扣游戏
1448 | final_price_elem = discount_block.select_one(".discount_final_price")
1449 | if final_price_elem:
1450 | if "free" in final_price_elem.get("class", []):
1451 | price = "免费游戏"
1452 | else:
1453 | price = final_price_elem.text.strip()
1454 |
1455 | game_results.append({
1456 | "url": game_url,
1457 | "title": title,
1458 | "image_url": image_url,
1459 | "release_date": release_date,
1460 | "price": price
1461 | })
1462 | except Exception as e:
1463 | print(f"处理结果 {i} 时出错: {e}")
1464 | continue
1465 | finally:
1466 | login_driver.quit()
1467 |
1468 | if not game_results:
1469 | yield event.plain_result("⚠️ 解析搜索结果失败,请尝试其他关键词。")
1470 | return
1471 |
1472 | USER_STATES[user_id] = {
1473 | "type": "store_search",
1474 | "timestamp": time.time(),
1475 | "results": game_results,
1476 | "processed": False # 添加新标志,标记是否已处理用户选择
1477 | }
1478 |
1479 | # 渲染HTML为图片
1480 | html_content = Template(self.HTML_STORE_TEMPLATE).render(games=game_results)
1481 | image_url = await self.html_render(html_content, {})
1482 | yield event.image_result(image_url)
1483 |
1484 | # 启动会话控制器等待用户选择
1485 | try:
1486 | @session_waiter(timeout=30)
1487 | async def wait_for_store_selection(controller: SessionController, response_event: AstrMessageEvent):
1488 | if response_event.get_sender_id() != user_id:
1489 | return
1490 |
1491 | # 检查会话是否已处理
1492 | if user_id not in USER_STATES or USER_STATES[user_id].get("processed", True):
1493 | return
1494 |
1495 | message = response_event.message_str.strip()
1496 |
1497 | # 检查是否是数字选择
1498 | if message.isdigit():
1499 | selection = int(message)
1500 | if 1 <= selection <= len(game_results):
1501 | # 标记已处理
1502 | USER_STATES[user_id]["processed"] = True
1503 |
1504 | # 获取选中的游戏链接
1505 | selected_game = game_results[selection - 1]
1506 |
1507 | message_result = response_event.make_result()
1508 | message_result.chain = [Comp.Plain(f"✅ 您选择了: {selected_game['title']}\n正在获取详情...")]
1509 | await response_event.send(message_result)
1510 |
1511 | # 跳转到选中的游戏页面
1512 | await process_steam_store(response_event, selected_game["url"])
1513 | controller.stop()
1514 | else:
1515 | message_result = response_event.make_result()
1516 | message_result.chain = [Comp.Plain(f"⚠️ 请输入1-{len(game_results)}的数字")]
1517 | await response_event.send(message_result)
1518 | controller.keep(timeout=20)
1519 | else:
1520 | message_result = response_event.make_result()
1521 | message_result.chain = [Comp.Plain("⚠️ 请输入数字选择游戏")]
1522 | await response_event.send(message_result)
1523 | controller.keep(timeout=20)
1524 |
1525 | await wait_for_store_selection(event)
1526 |
1527 | except TimeoutError:
1528 | # 超时处理 - 默认选择第一项
1529 | # 检查是否已经处理,避免重复处理
1530 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "store_search" and not USER_STATES[user_id].get("processed", False):
1531 | USER_STATES[user_id]["processed"] = True
1532 | default_game = USER_STATES[user_id]["results"][0]
1533 | yield event.plain_result(f"⏱️ 等待选择超时,默认选择第一项: {default_game['title']}")
1534 | await process_steam_store(event, default_game["url"])
1535 |
1536 | finally:
1537 | # 清理用户状态
1538 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "store_search":
1539 | del USER_STATES[user_id]
1540 |
1541 | except Exception as e:
1542 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "store_search":
1543 | del USER_STATES[user_id]
1544 | yield event.plain_result(f"❌ 搜索失败: {e}")
1545 |
1546 | async def steam_user_search(self, search_user_name: str, event: AstrMessageEvent):
1547 | """搜索 Steam 用户并返回前10个结果"""
1548 | user_id = event.get_sender_id()
1549 |
1550 | # 检查用户是否已经有搜索会话
1551 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "user_search":
1552 | yield event.plain_result("您有一个正在进行的搜索会话,请先完成或等待会话超时。")
1553 | return
1554 |
1555 | yield event.plain_result(f"🔍 正在搜索用户: {search_user_name}...")
1556 |
1557 | try:
1558 | url = f"https://steamcommunity.com/search/users/#text={search_user_name}"
1559 | driver = create_driver(apply_login=True, url=url)
1560 | user_results = []
1561 |
1562 | try:
1563 | driver.get(url)
1564 | # 等待页面加载,Steam用户搜索需要额外时间渲染结果
1565 | time.sleep(3)
1566 |
1567 | soup = BeautifulSoup(driver.page_source, "html.parser")
1568 |
1569 | # 检查是否没有用户
1570 | no_user = soup.select_one(".search_results_error h2")
1571 | if no_user and "没有符合您搜索的用户" in no_user.text:
1572 | yield event.plain_result(f"❌ 没有找到名为 {search_user_name} 的用户。")
1573 | return
1574 |
1575 | # 获取搜索结果
1576 | search_rows = soup.select(".search_row")
1577 |
1578 | if not search_rows:
1579 | yield event.plain_result("⚠️ 未找到用户搜索结果。")
1580 | return
1581 |
1582 | # 限制为前10个结果
1583 | search_rows = search_rows[:10]
1584 |
1585 | for row in search_rows:
1586 | try:
1587 | # 获取用户名和链接
1588 | name_elem = row.select_one(".searchPersonaName")
1589 | if not name_elem:
1590 | continue
1591 |
1592 | name = name_elem.text.strip()
1593 | profile_url = name_elem["href"]
1594 |
1595 | # 获取头像
1596 | avatar_elem = row.select_one(".avatarMedium img")
1597 | avatar_url = avatar_elem["src"] if avatar_elem else None
1598 |
1599 | # 获取地区信息
1600 | location = None
1601 | persona_info = row.select_one(".searchPersonaInfo")
1602 | if persona_info:
1603 | # 寻找国旗图标,它总是紧跟在地区信息后面
1604 | flag_img = persona_info.select_one("img[src*='countryflags']")
1605 | if flag_img:
1606 | # 提取国旗前的文本,但只取同一行的文本(地区信息)
1607 | location_text = ""
1608 |
1609 | # 获取国旗图片的父节点内容
1610 | for content in flag_img.parent.contents:
1611 | # 只提取国旗图片前的文本节点
1612 | if content == flag_img:
1613 | break
1614 | if isinstance(content, str):
1615 | location_text += content
1616 |
1617 | # 清理文本
1618 | location = location_text.strip()
1619 |
1620 | # 如果包含换行符,说明可能混入了别名,只取最后一部分
1621 | if "\n" in location:
1622 | location = location.split("\n")[-1].strip()
1623 |
1624 | # 替换HTML特殊字符
1625 | if " " in location:
1626 | location = location.replace(" ", "").strip()
1627 |
1628 | # 获取自定义URL
1629 | custom_url = None
1630 | match_info = row.select_one(".search_match_info")
1631 | if match_info:
1632 | url_div = match_info.select_one("div")
1633 | if url_div and "自定义 URL:" in url_div.text:
1634 | custom_url = url_div.text.replace("自定义 URL:", "").strip()
1635 |
1636 | user_results.append({
1637 | "url": profile_url,
1638 | "name": name,
1639 | "avatar_url": avatar_url,
1640 | "location": location,
1641 | "custom_url": custom_url
1642 | })
1643 | except Exception as e:
1644 | print(f"处理用户结果时出错: {e}")
1645 | continue
1646 | finally:
1647 | driver.quit()
1648 |
1649 | if not user_results:
1650 | yield event.plain_result("⚠️ 解析用户搜索结果失败,请尝试其他关键词。")
1651 | return
1652 |
1653 | # 保存搜索结果到用户状态
1654 | USER_STATES[user_id] = {
1655 | "type": "user_search",
1656 | "timestamp": time.time(),
1657 | "results": user_results,
1658 | "processed": False # 添加新标志
1659 | }
1660 |
1661 | # 渲染HTML为图片
1662 | html_content = Template(self.HTML_USER_TEMPLATE).render(users=user_results)
1663 | image_url = await self.html_render(html_content, {})
1664 | yield event.image_result(image_url)
1665 |
1666 | # 启动会话控制器等待用户选择
1667 | try:
1668 | @session_waiter(timeout=30)
1669 | async def wait_for_user_selection(controller: SessionController, response_event: AstrMessageEvent):
1670 | if response_event.get_sender_id() != user_id:
1671 | return
1672 |
1673 | # 检查会话是否已处理
1674 | if user_id not in USER_STATES or USER_STATES[user_id].get("processed", True):
1675 | return
1676 |
1677 | message = response_event.message_str.strip()
1678 |
1679 | # 检查是否是数字选择
1680 | if message.isdigit():
1681 | selection = int(message)
1682 | if 1 <= selection <= len(user_results):
1683 | # 标记已处理
1684 | USER_STATES[user_id]["processed"] = True
1685 |
1686 | # 获取选中的用户链接
1687 | selected_user = user_results[selection - 1]
1688 |
1689 | message_result = response_event.make_result()
1690 | message_result.chain = [Comp.Plain(f"✅ 您选择了: {selected_user['name']}\n正在获取详情...")]
1691 | await response_event.send(message_result)
1692 |
1693 | # 跳转到选中的用户页面
1694 | await process_steam_profile(response_event, selected_user["url"])
1695 | controller.stop()
1696 | else:
1697 | message_result = response_event.make_result()
1698 | message_result.chain = [Comp.Plain(f"⚠️ 请输入1-{len(user_results)}的数字")]
1699 | await response_event.send(message_result)
1700 | controller.keep(timeout=20)
1701 | else:
1702 | message_result = response_event.make_result()
1703 | message_result.chain = [Comp.Plain("⚠️ 请输入数字选择用户")]
1704 | await response_event.send(message_result)
1705 | controller.keep(timeout=20)
1706 |
1707 | await wait_for_user_selection(event)
1708 |
1709 | except TimeoutError:
1710 | # 超时处理 - 默认选择第一项,增加条件判断
1711 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "user_search" and not USER_STATES[user_id].get("processed", False):
1712 | USER_STATES[user_id]["processed"] = True
1713 | default_user = USER_STATES[user_id]["results"][0]
1714 | yield event.plain_result(f"⏱️ 等待选择超时,默认选择第一项: {default_user['name']}")
1715 | await process_steam_profile(event, default_user["url"])
1716 |
1717 | finally:
1718 | # 清理用户状态
1719 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "user_search":
1720 | del USER_STATES[user_id]
1721 |
1722 | except Exception as e:
1723 | if user_id in USER_STATES and USER_STATES[user_id]["type"] == "user_search":
1724 | del USER_STATES[user_id]
1725 | yield event.plain_result(f"❌ 搜索用户失败: {e}")
1726 |
1727 | @filter.command("sss")
1728 | async def search_steam_store(self, event: AstrMessageEvent):
1729 | """搜索 Steam 商店游戏信息\n用法:/sss 游戏名"""
1730 | args = event.message_str.split(maxsplit=1)
1731 | if len(args) < 2:
1732 | yield event.plain_result("请输入要搜索的游戏名称,例如:/sss 犹格索托斯的庭院")
1733 | return
1734 |
1735 | search_game_name = args[1]
1736 | async for response in self.steam_store_search(search_game_name, event):
1737 | yield response
1738 |
1739 | @filter.command("ssu")
1740 | async def search_steam_user(self, event: AstrMessageEvent):
1741 | """搜索 Steam 用户信息\n用法:/ssu 用户名"""
1742 | args = event.message_str.split(maxsplit=1)
1743 | if len(args) < 2:
1744 | yield event.plain_result("请输入要搜索的 Steam 用户名,例如:/ssu m4a1_death-Dawn")
1745 | return
1746 |
1747 | search_user_name = args[1]
1748 | async for result in self.steam_user_search(search_user_name, event):
1749 | yield result
1750 |
1751 | @filter.command("ssl")
1752 | async def steam_login(self, event: AstrMessageEvent):
1753 | """设置Steam登录状态\n用法:
1754 | /ssl enable - 启用Steam登录
1755 | /ssl disable - 禁用Steam登录
1756 | /ssl status - 查看当前登录状态
1757 | /ssl store [cookies文本] - 设置Steam商店cookies
1758 | /ssl community [cookies文本] - 设置Steam社区cookies
1759 | /ssl test - 测试Steam登录状态"""
1760 | # 在函数内部导入所需函数
1761 | from .steam_login import enable_steam_login, disable_steam_login, save_steam_cookies, get_cookie_status, test_steam_login
1762 |
1763 | args = event.message_str.split(maxsplit=1)
1764 | if len(args) < 2:
1765 | yield event.plain_result(
1766 | "⚠️ 请提供参数:\n"
1767 | "/ssl enable - 启用Steam登录\n"
1768 | "/ssl disable - 禁用Steam登录\n"
1769 | "/ssl status - 查看当前登录状态\n"
1770 | "/ssl store [cookies文本] - 设置Steam商店cookies\n"
1771 | "/ssl community [cookies文本] - 设置Steam社区cookies\n"
1772 | "/ssl test - 测试Steam登录状态"
1773 | )
1774 | return
1775 |
1776 | cmd = args[1].strip()
1777 |
1778 | if cmd == "enable":
1779 | if enable_steam_login():
1780 | # 更新插件配置
1781 | self.enable_steam_login = True
1782 | self.config["enable_steam_login"] = True
1783 | self.config.save_config()
1784 | yield event.plain_result("✅ 已启用Steam登录功能")
1785 | else:
1786 | yield event.plain_result("❌ 启用Steam登录功能失败")
1787 |
1788 | elif cmd == "disable":
1789 | if disable_steam_login():
1790 | # 更新插件配置
1791 | self.enable_steam_login = False
1792 | self.config["enable_steam_login"] = False
1793 | self.config.save_config()
1794 | yield event.plain_result("✅ 已禁用Steam登录功能")
1795 | else:
1796 | yield event.plain_result("❌ 禁用Steam登录功能失败")
1797 |
1798 | elif cmd == "status":
1799 | status = get_cookie_status()
1800 | yield event.plain_result(f"当前状态:\n{status}")
1801 |
1802 | elif cmd.startswith("store"):
1803 | parts = cmd.split(maxsplit=1)
1804 | if len(parts) < 2:
1805 | yield event.plain_result(
1806 | "⚠️ 请提供Steam商店(store)的cookies文本\n"
1807 | "格式如: /ssl store steamLoginSecure=xxx; steamid=xxx; ...\n\n"
1808 | "获取方法:\n"
1809 | "1. 在浏览器中登录Steam商店(https://store.steampowered.com)\n"
1810 | "2. 按F12打开开发者工具\n"
1811 | "3. 切换到'应用'/'Application'/'存储'/'Storage'标签\n"
1812 | "4. 左侧选择'Cookies' > 'https://store.steampowered.com'\n"
1813 | "5. 复制所有cookies内容 (至少需要包含steamLoginSecure)"
1814 | )
1815 | return
1816 |
1817 | cookies_str = parts[1]
1818 | success, message = save_steam_cookies(cookies_str, "store")
1819 | if success:
1820 | # 更新插件配置
1821 | self.steam_store_cookies = cookies_str
1822 | self.config["steam_store_cookies"] = cookies_str
1823 | self.config.save_config()
1824 | yield event.plain_result(message)
1825 |
1826 | elif cmd.startswith("community"):
1827 | parts = cmd.split(maxsplit=1)
1828 | if len(parts) < 2:
1829 | yield event.plain_result(
1830 | "⚠️ 请提供Steam社区(community)的cookies文本\n"
1831 | "格式如: /ssl community steamLoginSecure=xxx; steamid=xxx; ...\n\n"
1832 | "获取方法:\n"
1833 | "1. 在浏览器中登录Steam社区(https://steamcommunity.com)\n"
1834 | "2. 按F12打开开发者工具\n"
1835 | "3. 切换到'应用'/'Application'/'存储'/'Storage'标签\n"
1836 | "4. 左侧选择'Cookies' > 'https://steamcommunity.com'\n"
1837 | "5. 复制所有cookies内容 (至少需要包含steamLoginSecure)"
1838 | )
1839 | return
1840 |
1841 | cookies_str = parts[1]
1842 | success, message = save_steam_cookies(cookies_str, "community")
1843 | if success:
1844 | # 更新插件配置
1845 | self.steam_community_cookies = cookies_str
1846 | self.config["steam_community_cookies"] = cookies_str
1847 | self.config.save_config()
1848 | yield event.plain_result(message)
1849 |
1850 | elif cmd == "test":
1851 | yield event.plain_result("🔄 正在测试Steam登录状态,请稍候...")
1852 | result = await test_steam_login()
1853 | yield event.plain_result(result)
1854 |
1855 | else:
1856 | yield event.plain_result(
1857 | "⚠️ 未知命令,可用命令:\n"
1858 | "/ssl enable - 启用Steam登录\n"
1859 | "/ssl disable - 禁用Steam登录\n"
1860 | "/ssl status - 查看当前登录状态\n"
1861 | "/ssl store [cookies文本] - 设置Steam商店cookies\n"
1862 | "/ssl community [cookies文本] - 设置Steam社区cookies\n"
1863 | "/ssl test - 测试Steam登录状态"
1864 | )
1865 |
1866 | # 在配置变更时应用新配置
1867 | def on_config_changed(self):
1868 | """当插件配置在WebUI上被修改时调用"""
1869 | # 读取新配置
1870 | self.enable_steam_login = self.config.get("enable_steam_login", False)
1871 | self.steam_store_cookies = self.config.get("steam_store_cookies", "")
1872 | self.steam_community_cookies = self.config.get("steam_community_cookies", "")
1873 |
1874 | # 应用新配置
1875 | self._apply_config()
1876 |
1877 |
--------------------------------------------------------------------------------