├── 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 | [![License: MIT](https://img.shields.io/badge/License-AGPL3.0-blue.svg)](https://opensource.org/licenses/AGPL3.0) 6 | ![Platform](https://img.shields.io/badge/Platform-Windows-lightblue) 7 | [![PRs Welcome](https://img.shields.io/badge/PRs-Welcome-brightgreen)](README.md) 8 | [![Contributors](https://img.shields.io/github/contributors/inori-3333/astrbot_plugin_steamshot?color=green)](https://github.com/inori-3333/astrbot_plugin_steamshot/graphs/contributors) 9 | [![Last Commit](https://img.shields.io/github/last-commit/inori-3333/astrbot_plugin_steamshot)](https://github.com/inori-3333/astrbot_plugin_steamshot/commits/main) 10 | 11 |
12 | 13 |
14 | 15 | [![Moe Counter](https://count.getloli.com/get/@astrbot_plugin_steamshot?theme=moebooru)](https://github.com/inori-3333/astrbot_plugin_steamshot) 16 | 17 |
18 | 19 | + 一个AstrBot插件。A plugin for AstrBot. 20 | > 如果您觉得对您有用,请点一个star,我会学猫娘叫。 21 | ![使用示例](star.png) 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 | ![使用示例](sample.png) 96 | ![使用示例2](sample2.png) 97 | 98 | ## 指令使用指南 99 | 根据收到的steam链接自动解析指定界面,插件会自动检测对话中出现的steam链接,并返回对应页面的网页截图和摘要信息(现仅支持steam商店界面、个人主页界面和创意工坊界面)。 100 | ``` 101 | 使用 /sss 指令搜索steam商店,使用方法: /sss + 游戏名,如: /sss 不/存在的你,和我 102 | 使用 /ssu 指令搜索steam用户,使用方法: /ssu + 用户名,如: /ssu m4a1_death-Dawn 103 | ``` 104 | 105 | ![使用示例5](sample5.png) 106 | ![使用示例4](sample4.png) 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 | ![偏好](preferences.png) 127 | 128 | ![步骤1](step1.png) 129 | ![步骤2](step2.png) 130 | ![步骤3](step3.png) 131 | ![步骤4](step4.png) 132 | ![使用示例7](sample7.png) 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 | ![使用示例6](sample6.png) 168 | 169 | ## v1.6.1 170 | + 支持解析steam主页最新动态,并改善排版 171 | + 支持解析steam个人简介中的链接(之前考虑到可能会有些不良链接,不过现在还是觉得应该问题不大,还是放出来了) 172 | ![使用示例3](sample3.png) 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 |
1197 | {% if game.image_url %} 1198 | {{ game.title }} 1199 | {% endif %} 1200 |
1201 |

{{ game.title }}

1202 |
1203 | {% if game.release_date %} 1204 |
上架时间: {{ game.release_date }}
1205 | {% else %} 1206 |
上架时间: 未知
1207 | {% endif %} 1208 | {% if game.price %} 1209 |
价格: {{ game.price }}
1210 | {% else %} 1211 |
价格: 未知
1212 | {% endif %} 1213 |
1214 |
1215 |
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 | {{ user.name }} 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 | --------------------------------------------------------------------------------