Skip to content

Commit d1a5e35

Browse files
committed
Initial commit
0 parents  commit d1a5e35

File tree

9 files changed

+604
-0
lines changed

9 files changed

+604
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
steamcmd/
2+
.idea/
3+
.venv/

main.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from quart import Quart, redirect, request, send_file
2+
3+
from services import (steam_proxied)
4+
from steam import workshop_download, collection_download
5+
6+
app = Quart(__name__)
7+
8+
9+
@app.route("/")
10+
async def index():
11+
return redirect("/workshop/")
12+
13+
14+
@app.route("/dl-workshop/<int:app_id>/<int:workshop_id>")
15+
async def download_workshop(app_id, workshop_id):
16+
path = await workshop_download(app_id, workshop_id)
17+
if path is None:
18+
return "Workshop not found"
19+
print(path)
20+
return await send_file(path)
21+
22+
23+
@app.route("/dl-collection/<int:app_id>/<int:workshop_id>")
24+
async def download_collection(app_id, workshop_id):
25+
path = await collection_download(app_id, workshop_id)
26+
if path is None:
27+
return "Collection not found"
28+
29+
return await send_file(path)
30+
31+
32+
@app.route("/<path:path>")
33+
async def steam_proxied_wrapper(path):
34+
return await steam_proxied(path, "steamcommunity.com",
35+
headers=dict(request.headers),
36+
data=await request.data,
37+
method=request.method,
38+
params=request.args,
39+
cookies=request.cookies,
40+
host=request.host)
41+
42+
43+
if __name__ == "__main__":
44+
app.run(host='0.0.0.0', debug=True)

requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Quart~=0.19.5
2+
aiofiles~=23.2.1
3+
httpx~=0.27.0
4+
aiozipstream~=0.4
5+
async-cache~=1.1.1

services.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from re import compile
2+
3+
from cache import AsyncTTL
4+
from httpx import AsyncClient
5+
from quart.wrappers.response import DataBody, Response
6+
7+
host_fixer = compile(r"^http(s?)://[\w.:]+/")
8+
info = compile(rb"onclick=\"(?:SubscribeItem|SubscribeCollection|SubscribeCollectionItem)"
9+
rb"\(\s+'(\d+)',\s+'(\d+)'\s+\);\"")
10+
item_rex = compile(rb"(<a\s+onclick=\"SubscribeCollectionItem\(\s+'(\d+)',\s+'(\d+)'\s+\);"
11+
rb"\"\s+id=\"SubscribeItemBtn\d+\"\s+class=\"[^\"]*\"\s*>[\s\S]*?</a>)")
12+
13+
14+
@AsyncTTL(time_to_live=60, maxsize=40 << 10)
15+
async def replace_headers(headers: dict, target_host: str) -> dict:
16+
if "Referer" in headers:
17+
headers["Referer"] = host_fixer.sub("https://%s/" % target_host, headers["Referer"])
18+
19+
if "Origin" in headers:
20+
headers["Origin"] = host_fixer.sub("https://%s/" % target_host, headers["Origin"])
21+
22+
if "Host" in headers:
23+
headers["Host"] = "steamcommunity.com"
24+
25+
if "x-frame-options" in headers:
26+
del headers["x-frame-options"]
27+
28+
if "content-security-policy" in headers:
29+
del headers["content-security-policy"]
30+
31+
if "content-encoding" in headers:
32+
del headers["content-encoding"]
33+
34+
if "access-control-allow-origin" in headers:
35+
del headers["access-control-allow-origin"]
36+
37+
if "location" in headers:
38+
del headers["location"]
39+
40+
return headers
41+
42+
43+
@AsyncTTL(time_to_live=60, maxsize=40 << 10)
44+
async def fix_body(content, host: str, target_host: str) -> bytes:
45+
target_host = target_host.encode()
46+
content = content.replace(b"https://%s/" % target_host, b"/")
47+
content = content.replace(b"https://%s" % target_host, b"")
48+
content = content.replace(target_host, host.encode())
49+
return content
50+
51+
52+
def try_to_add_download_button(data_body):
53+
if b"<a onclick=\"SubscribeItem(" in data_body:
54+
index = data_body.find(b"<a onclick=\"SubscribeItem(")
55+
56+
if index != -1:
57+
while chr(data_body[index - 1]).isspace():
58+
index -= 1
59+
60+
data_info = info.search(data_body[index:])
61+
if not data_info:
62+
return data_body
63+
64+
workshop_id, app_id = data_info.groups()
65+
if not workshop_id or not app_id:
66+
return data_body
67+
68+
index -= 5
69+
70+
data_body = data_body[:index] + (b"<div><a href=\"/dl-workshop/%s/%s\" class=\"btn_darkred_white_innerfade "
71+
b"btn_border_2px btn_medium\" style=\"position: relative\"> <div "
72+
b"class=\"followIcon\"></div> <span class=\"subscribeText\"> "
73+
b"<div>Download</div> </span> </a> </div>" % (
74+
app_id, workshop_id)) + data_body[index:]
75+
elif b"<a class=\"general_btn subscribe\" onclick=\"SubscribeCollection(":
76+
index = data_body.find(b"<a class=\"general_btn subscribe\" onclick=\"SubscribeCollection(")
77+
78+
if index != -1:
79+
data_info = info.search(data_body[index:])
80+
if not data_info:
81+
return data_body
82+
83+
workshop_id, app_id = data_info.groups()
84+
if not workshop_id or not app_id:
85+
return data_body
86+
87+
index -= 5
88+
89+
data_body = data_body[:index] + (b"<a class=\"general_btn subscribe\" "
90+
b"style=\"background: #640000; color: white;\" "
91+
b"href=\"/dl-collection/%s/%s\">"
92+
b"<div class=\"followIcon\"></div> <span "
93+
b"class=\"subscribeText\">Download Collection</span> </a>" % (
94+
app_id, workshop_id)) + data_body[index:]
95+
96+
data_body = item_rex.sub(lambda m: b"<a style=\"background: #640000; color: white;\""
97+
b" href=\"/dl-workshop/%s/%s\""
98+
b" class=\"general_btn subscribe\""
99+
b"><div class=\"followIcon\"></div></a> %s" % (
100+
m.group(3), m.group(2), m.group(1)), data_body)
101+
102+
return data_body
103+
104+
105+
async def steam_proxied(path: str, target_host: str, headers: dict, host: str, **kwargs) -> Response:
106+
headers = await replace_headers(headers, target_host)
107+
108+
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0"
109+
110+
async with AsyncClient() as client:
111+
response = await client.request(url="https://%s/%s" % (target_host, path),
112+
headers=headers,
113+
**kwargs)
114+
115+
data_body = response.content
116+
data_headers = dict(response.headers.items())
117+
118+
data_body = await fix_body(data_body, host, "community.akamai.steamstatic.com")
119+
data_headers = await replace_headers(data_headers, "community.akamai.steamstatic.com")
120+
121+
data_body = await fix_body(data_body, host, "steamcommunity.com")
122+
data_headers = await replace_headers(data_headers, "steamcommunity.com")
123+
124+
data_body = try_to_add_download_button(data_body)
125+
126+
data_headers["content-length"] = len(data_body)
127+
128+
return Response(response=DataBody(data_body),
129+
headers=data_headers,
130+
status=response.status_code)

steam.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from asyncio import run
2+
from contextlib import suppress
3+
from os import getcwd
4+
from pathlib import Path
5+
from re import compile
6+
from typing import List
7+
8+
from aiofiles import open as async_open
9+
from cache import AsyncTTL
10+
from httpx import AsyncClient
11+
from zipstream import AioZipStream
12+
13+
from steamcmdwrapper import SteamCMDWrapper, SteamCMDException, SteamCommand
14+
15+
steamcmd_path = Path(getcwd()) / "steamcmd"
16+
17+
steam_path = steamcmd_path / "steam"
18+
steam_path.mkdir(exist_ok=True, parents=True)
19+
20+
steam = SteamCMDWrapper(steamcmd_path)
21+
22+
with suppress(SteamCMDException):
23+
run(steam.install())
24+
25+
workshop_pattern = compile(rb'<a\s+href="https://steamcommunity\.com/sharedfiles/filedetails/\?id=('
26+
rb'\d+)">\s*<div\s+class="workshopItemTitle">([^<]+)</div>\s*</a>')
27+
collection_title_pattern = compile(rb'<div\s+class=\"workshopItemTitle\">([^<]+)</div>')
28+
29+
30+
@AsyncTTL(time_to_live=60, maxsize=40 << 10)
31+
async def get_collection_items(workshop_id) -> tuple[str, list[tuple[int, str]]]:
32+
var = []
33+
title = ""
34+
35+
async with AsyncClient() as client:
36+
response = await client.get(f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}")
37+
38+
response.raise_for_status()
39+
40+
content = await response.aread()
41+
42+
title = collection_title_pattern.search(content)
43+
cols = workshop_pattern.findall(content)
44+
45+
title = title.group(1).decode()
46+
47+
if not cols or not title:
48+
return title, var
49+
50+
for coll in cols:
51+
var.append((int(coll[0]), coll[1].decode()))
52+
53+
return title, var
54+
55+
56+
@AsyncTTL(time_to_live=60, maxsize=40 << 10)
57+
async def get_workshop_name(workshop_id):
58+
title = ""
59+
60+
async with AsyncClient() as client:
61+
response = await client.get(f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}")
62+
63+
response.raise_for_status()
64+
65+
content = await response.aread()
66+
67+
title = collection_title_pattern.search(content)
68+
if not title:
69+
return None
70+
71+
title = title.group(1).decode()
72+
73+
return title
74+
75+
76+
async def workshop_download(app_id, workshop_id, validate=True):
77+
workshop_name = await get_workshop_name(workshop_id)
78+
if not workshop_name:
79+
return None
80+
81+
workshop_download_path = (steam_path / "steamapps" / "workshop" / "content" / str(app_id) / str(workshop_id))
82+
workshop_zip = workshop_download_path.as_posix() + "%s .zip" % workshop_name
83+
84+
if not workshop_download_path.exists():
85+
try:
86+
await steam.workshop_update(app_id, workshop_id, steam_path, validate, n_tries=3)
87+
except Exception as e:
88+
return "an error occured while downloading the item: " + str(e)
89+
90+
if not workshop_download_path.exists():
91+
return None
92+
93+
items = []
94+
add_directory(items, workshop_download_path, "%s %s" % (workshop_id, workshop_name))
95+
atomic = AioZipStream(items, chunksize=32768)
96+
97+
async with async_open(workshop_zip, mode='wb') as z:
98+
async for chunk in atomic.stream():
99+
await z.write(chunk)
100+
101+
return workshop_zip
102+
103+
104+
def is_empty(item_path: Path):
105+
return not any(item_path.iterdir())
106+
107+
108+
def add_directory(items, item_path, target_dir):
109+
if not item_path.exists():
110+
return
111+
112+
if item_path.is_file():
113+
items.append({"file": item_path, "name": target_dir})
114+
elif item_path.is_dir():
115+
for item in item_path.iterdir():
116+
item_name = item.name.replace('\\', '/')
117+
add_directory(items, item, f"{target_dir}/{item_name}")
118+
119+
120+
async def collection_download(app_id: int, workshop_id: int, validate: bool = True, batch_size: int = 20):
121+
game_path = steam_path / "steamapps" / "workshop" / "content" / str(app_id)
122+
title, workshop_matches = await get_collection_items(workshop_id)
123+
124+
if not workshop_matches or not title:
125+
return None
126+
127+
collection_path = game_path / f"{workshop_id} {title}.zip"
128+
129+
if collection_path.exists() and collection_path.stat().st_size > 0:
130+
return collection_path
131+
132+
items = list()
133+
134+
while workshop_matches:
135+
batch = workshop_matches[:batch_size]
136+
workshop_matches = workshop_matches[batch_size:]
137+
138+
sc = SteamCommand(steam_path)
139+
for item_id, work_title in batch:
140+
item_path = game_path / str(item_id)
141+
142+
add_directory(items, item_path, f"{item_id} {work_title}")
143+
144+
if item_path.exists() and not is_empty(item_path):
145+
continue
146+
147+
sc.workshop_download_item(app_id, item_id, validate)
148+
149+
if sc.commands:
150+
await steam.execute(sc, n_tries=3)
151+
152+
atomic = AioZipStream(items, chunksize=32768)
153+
async with async_open(collection_path, mode='wb') as z:
154+
async for chunk in atomic.stream():
155+
await z.write(chunk)
156+
157+
return collection_path

steamcmdwrapper/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .exceptions import SteamCMDException, SteamCMDDownloadException, SteamCMDInstallException
2+
from .steam_wrapper import SteamCMDWrapper
3+
from .steam_command import SteamCommand

steamcmdwrapper/exceptions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class SteamCMDException(Exception):
2+
def __init__(self, message=None, *args, **kwargs):
3+
self.message = message
4+
super(SteamCMDException, self).__init__(message, *args)
5+
6+
def __unicode__(self):
7+
return repr(self.message)
8+
9+
def __str__(self):
10+
return repr(self.message)
11+
12+
13+
class SteamCMDDownloadException(SteamCMDException):
14+
def __init__(self, *args, **kwargs):
15+
super(SteamCMDDownloadException, self).__init__(*args, **kwargs)
16+
17+
18+
class SteamCMDInstallException(SteamCMDException):
19+
def __init__(self, *args, **kwargs):
20+
super(SteamCMDInstallException, self).__init__(*args, **kwargs)

0 commit comments

Comments
 (0)