├── version.txt ├── requirements.txt ├── .gitattributes ├── steamcmd └── steamcmd.exe ├── LICENSE ├── README.md ├── .github └── workflows │ └── run.yml ├── html └── index.html └── app.py /version.txt: -------------------------------------------------------------------------------- 1 | 1.0.8 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | pywebview 3 | pywinpty 4 | requests 5 | loguru -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /steamcmd/steamcmd.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mnaisuka/SteamcmdGui/HEAD/steamcmd/steamcmd.exe -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mnaisuka 4 | 5 | Permission is granted to anyone to use, copy, modify, merge, publish copies of this software, but the original author information must be retained. 6 | 7 | Disclaimer: 8 | The author is not liable for any damages or liabilities arising from the use of this software. 9 | 10 | Non-commercial use: 11 | This code may not be used for commercial purposes. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 软件描述 2 | 3 | 本软件通过使用 Cookies 动态获取账号的订阅列表,并将其传递给 SteamCMD 进行下载。这样,即使没有游戏本体,用户也可以像在 Steam 客户端中一样轻松下载模组。 4 | 5 | --- 6 | 7 | ## 使用说明 8 | 9 | ### 0. 首次运行 10 | 请耐心等待 SteamCMD 更新完成。 11 | 12 | ### 1. 必须参数 13 | 14 | - **URL**: 15 | 以 PZ(Project Zomboid)为例,其他游戏同理。请打开 [PZ 工坊首页](https://steamcommunity.com/app/108600/workshop/),鼠标进入并点击 **浏览 > 订阅的物品**,等待页面跳转后复制网址并填入软件。 16 | 17 | - **Cookies**: 18 | 请参考 [视频教程](https://www.bilibili.com/video/BV1xqyfYZE6Y) 获取相关信息。 19 | 20 | --- 21 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: 自动编译并发布 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - name: 读取仓库 17 | uses: actions/checkout@v2 18 | 19 | - name: 指定语言 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.12" 23 | 24 | - name: 安装环境 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install pip-tools 28 | pip install pyinstaller 29 | pip install -r requirements.txt 30 | 31 | - name: 构建应用 32 | run: | 33 | pyinstaller -F -w --add-data html:html --contents-directory=. app.py 34 | 35 | - name: 添加依赖 36 | run: | 37 | mv steamcmd dist 38 | 39 | - name: 读取版本 40 | run: | 41 | $VERSION = Get-Content version.txt # 读取文件内容 42 | Write-Output "version is $VERSION" 43 | echo "version=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append 44 | 45 | - name: 修改版本 46 | id: increment_version 47 | run: | 48 | $VERSION = "${{ env.version }}" 49 | $parts = $VERSION -split '\.' 50 | $patch = [int]$parts[2] + 1 51 | $NEW_VERSION = "$($parts[0]).$($parts[1]).$patch" 52 | Write-Output "Current version is $NEW_VERSION" 53 | Set-Content version.txt $NEW_VERSION 54 | echo "new_version=$NEW_VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append 55 | 56 | - name: 推送版本 57 | run: | 58 | Write-Output "Current version is ${{ env.new_version }}" 59 | git config --local user.email "2567810193@qq.com" 60 | git config --local user.name "Mnaisuka" 61 | git add version.txt 62 | git commit -m "Bump version to ${{ env.new_version }}" 63 | git tag "v${{ env.new_version }}" 64 | git push origin main --tags 65 | 66 | - name: 打包发行版 67 | run: | 68 | cd dist 69 | powershell -Command "Compress-Archive -Path * -DestinationPath ../steamcmd_gui.ver${{ env.new_version }}.zip -Force" 70 | 71 | - name: 推送发行版 72 | id: create_release 73 | uses: softprops/action-gh-release@v1 74 | with: 75 | tag_name: "v${{ env.new_version }}" 76 | files: steamcmd_gui.ver${{ env.new_version }}.zip 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 83 | 84 | 85 | 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
索引名称状态
99 |
100 |
101 | 102 |
下载
103 | 104 |
105 |
106 | 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os, re 2 | import webview 3 | from bs4 import BeautifulSoup 4 | import requests 5 | import urllib.parse 6 | from urllib.parse import urlparse 7 | import json 8 | from winpty import PTY 9 | import hashlib 10 | from loguru import logger 11 | from tkinter.messagebox import * 12 | from tkinter import messagebox 13 | 14 | 15 | class steamcmd: 16 | def __init__(self, root): 17 | self.root = root 18 | self.steam_cmd = f"{root}/steamcmd.exe" 19 | self.steam_cmd = os.path.abspath(self.steam_cmd) 20 | self.workshop_content = f"{root}/steamapps/workshop/content" 21 | self.workshop_content = os.path.abspath(self.workshop_content) 22 | self.md5 = "2629c77b1149eee9203e045e289e68ef" 23 | self.mutex = False 24 | 25 | def paste(self, text): 26 | pattern_map = { 27 | "Downloading item (\d+) ...": 1, 28 | 'Success. Downloaded item (\d+) to "(.*?)"': 2, 29 | "ERROR! Download item (\d+) failed \((.*)\).": -1, 30 | } 31 | for pattern in pattern_map: 32 | matches = re.findall(pattern, text, re.IGNORECASE) 33 | if len(matches) != 0: 34 | if type(matches[0]) == str: 35 | matches[0] = (matches[0], None) 36 | return [pattern_map[pattern], matches] 37 | return None 38 | 39 | def workshop_download(self, app_id, mods_id, obs): 40 | queue = dict() 41 | command = [] 42 | compnum = 0 43 | command.append(self.steam_cmd) 44 | command.append("+login anonymous") 45 | if type(mods_id) == str: # 字符串转数组 46 | mods_id = [mods_id] 47 | for _, mod_id in enumerate(mods_id): 48 | queue[mod_id] = [None, None] # 加入队列 49 | obs(0, None, [mod_id, _], compnum, len(mods_id)) 50 | command.append("+workshop_download_item {0} {1}".format(app_id, mod_id)) 51 | command.append("+quit") 52 | command = " ".join(command) 53 | # -------------------------- 54 | process = PTY(1000, 25) # cols值不应过小,否则输出会被截断 55 | process.spawn(command) 56 | while process.isalive(): 57 | line = process.read() 58 | if len(line) != 0: 59 | obs(-5, line, [], compnum, len(mods_id)) # 文本 60 | pack = self.paste(line.strip()) 61 | if pack != None: 62 | mod_id = pack[1][0][0] 63 | if pack[0] == 1: # 正在下载 64 | queue[mod_id] = [1, None] 65 | if pack[0] == 2: # 下载完成 66 | queue[mod_id] = [2, None] 67 | compnum = compnum + 1 68 | if pack[0] == -1: # 下载失败 69 | queue[mod_id] = [-1, pack[1][0][1]] 70 | obs( 71 | pack[0], None, [mod_id, queue[mod_id][1]], compnum, len(mods_id) 72 | ) # 进度回调 73 | del process 74 | return queue 75 | 76 | def update(self): 77 | md5_hash = hashlib.md5() 78 | with open(self.steam_cmd, "rb") as file: 79 | for chunk in iter(lambda: file.read(4096), b""): 80 | md5_hash.update(chunk) 81 | full_md5 = md5_hash.hexdigest() 82 | if full_md5 == self.md5: 83 | self.mutex = True 84 | window.title = "即将初始化..." 85 | logger.debug(window.title) 86 | process = PTY(1000, 25) # cols值不应过小,否则输出会被截断 87 | process.spawn(self.steam_cmd + " " + "+quit") 88 | while process.isalive(): 89 | line = process.read() 90 | if len(line) != 0: 91 | window.title = f"初始化: {line}" 92 | logger.debug(window.title) 93 | window.title = f"初始化结束..." 94 | logger.debug(window.title) 95 | self.mutex = False 96 | del process 97 | else: 98 | logger.debug("初始化结束...") 99 | 100 | def exclude(self, appid, mods_id): 101 | mods_path = os.path.join(self.workshop_content, appid) 102 | dirs = os.listdir(mods_path) 103 | excludes = [] 104 | for dir in dirs: 105 | full_path = os.path.join(mods_path, dir) 106 | if os.path.isdir(full_path): 107 | if not (dir in mods_id): 108 | excludes.append(dir) 109 | if len(excludes) > 0: 110 | result = messagebox.askyesno( 111 | "移除未订阅文件?", json.dumps(excludes, ensure_ascii=False) 112 | ) 113 | if result: 114 | for dir in excludes: 115 | mod_dir = os.path.join(mods_path, dir) 116 | os.rmdir(mod_dir) 117 | logger.debug(f"移除: {mod_dir}") 118 | 119 | 120 | def create_item(num, mid, text): 121 | window.evaluate_js(f"create_item(`{num}`,`{mid}`,`{text}`)") 122 | 123 | 124 | def update_item(mid, text): 125 | window.evaluate_js(f"update_item(`{mid}`,`{text}`)") 126 | 127 | 128 | def message_box(text): 129 | window.evaluate_js(f"alert(`{text}`)") 130 | 131 | 132 | def myworkshopfiles(url, appid, cookies, obs, verify=True): 133 | def get_url_params(url): 134 | params = {} 135 | if "?" in url: 136 | query_string = url.split("?")[1] 137 | for param in query_string.split("&"): 138 | key_value = param.split("=") 139 | params[key_value[0]] = key_value[1] 140 | return params 141 | 142 | headers = { 143 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) ", 144 | "Cookie": cookies, 145 | } 146 | params = { 147 | "appid": appid, 148 | "browsefilter": "mysubscriptions", 149 | "numperpage": "30", 150 | "p": "1", 151 | } 152 | try: 153 | obs(-5, "正在检索模组...", [], 0, 0) 154 | query_string = urllib.parse.urlencode(params) 155 | response = requests.get(f"{url}?{query_string}", headers=headers, verify=verify) 156 | soup = BeautifulSoup(response.text, "html.parser") 157 | pages = soup.find_all(class_="pagelink") 158 | if len(pages) == 0: 159 | pages = 1 160 | else: 161 | pages = pages[-1].text 162 | mods_num = soup.select_one(".workshopBrowsePagingInfo").text 163 | matches = re.findall(r"(\d+)", mods_num) 164 | mods_num = int(matches[-1]) if matches else 0 165 | mods = [] 166 | for index in range(int(pages)): 167 | params["p"] = index + 1 168 | curl = f"{url}?{urllib.parse.urlencode(params)}" 169 | page_data = requests.get(curl, headers=headers, verify=verify) 170 | page_soup = BeautifulSoup(page_data.text, "html.parser") 171 | parent = page_soup.select(".workshopItemPreviewHolder") 172 | for item in parent: 173 | link = item.parent.get("href") 174 | mods.append(get_url_params(link)["id"]) 175 | obs(-5, f"正在读取第{index + 1}/{pages}页...", [], 0, 0) 176 | obs(-5, f"已检索模组数量:{len(mods)}", [], 0, 0) 177 | return mods 178 | except requests.exceptions.SSLError as e: 179 | logger.exception("SSL错误:正在禁用并重试...") 180 | return myworkshopfiles(url, appid, cookies, obs, False) 181 | except Exception as e: 182 | logger.exception("未知错误") 183 | window.title = f"检索失败: {str(e)}" 184 | showerror("检索失败:", str(e)) 185 | return False 186 | 187 | 188 | class Api: 189 | def __init__(self): 190 | self.config = self.read() 191 | 192 | def read(self): 193 | if os.path.exists("config.ui"): 194 | with open("config.ui", "r") as file: 195 | return json.load(file) 196 | return {} 197 | 198 | def set(self, key, value): 199 | self.config[key] = value 200 | self.save() 201 | 202 | def get(self, key): 203 | return self.config.get(key, None) 204 | 205 | def save(self): 206 | with open("config.ui", "w") as file: 207 | json.dump(self.config, file) 208 | 209 | def update(self, url, cookies): 210 | try: 211 | if steam_api.mutex: # 判断是否在初始化 212 | message_box("正在初始化,请稍后再试...") 213 | return None 214 | parsed_url = urlparse(url) 215 | scheme = parsed_url.scheme 216 | netloc = parsed_url.netloc 217 | path = parsed_url.path 218 | query = parsed_url.query 219 | curl = scheme + "://" + netloc + path 220 | appid = urllib.parse.parse_qs(query)["appid"][0] 221 | 222 | def obs(code, text, args, num, total): 223 | """ 224 | #code -5 终端文本 225 | #code -1 下载失败 226 | #code 0 加入队列 227 | #code 1 正在下载 228 | #code 2 下载完成 229 | """ 230 | if code == 0: # 加入队列 231 | create_item(args[1], args[0], "队列") 232 | text = f"加入队列 {args[0]}" 233 | if code == 1: # 正在下载 234 | update_item(args[0], "正在下载") 235 | text = f"正在下载 {args[0]}" 236 | if code == 2: # 正在下载 237 | update_item(args[0], "下载完成") 238 | text = f"下载完成 {args[0]}" 239 | if code == -1: # 下载失败 240 | update_item(args[0], f"下载失败 {args[1]}") 241 | text = f"下载失败 {args[0]} {args[1]}" 242 | window.title = f"[{num}/{total}] " + (text or "") 243 | logger.debug(window.title) 244 | 245 | mods = myworkshopfiles(curl, appid, cookies, obs) 246 | if mods == False: 247 | return [] 248 | mods_status = steam_api.workshop_download(appid, mods, obs) 249 | steam_api.exclude(appid, mods) # 检索非订阅列表里的目录 250 | mod_dir = os.path.join(steam_api.workshop_content, appid) 251 | if os.path.exists(mod_dir): 252 | os.startfile(mod_dir) # 打开文件夹 253 | return mods_status 254 | except Exception as e: 255 | logger.exception("未知错误") 256 | showerror("未知错误:", str(e)) 257 | os._exit(0) 258 | 259 | def steamcmd(self): 260 | steam_api.update() 261 | 262 | 263 | if __name__ == "__main__": 264 | logger.add("app.log", mode="w", encoding="utf-8", enqueue=True) 265 | webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False 266 | steam_api = steamcmd("steamcmd") 267 | window = webview.create_window( 268 | "Hello world", "html/index.html", width=435, height=530, js_api=Api() 269 | ) 270 | webview.start(None, window, debug=True) 271 | 272 | # pyinstaller -F -w --add-data html:html --contents-directory=. app.py 273 | --------------------------------------------------------------------------------