├── 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 |
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 |
--------------------------------------------------------------------------------