Last active
March 30, 2025 03:50
-
-
Save Yoplitein/e335e4f20775409af70d579f3dfa60da to your computer and use it in GitHub Desktop.
Script to download CurseForge modpacks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from urllib import request | |
import argparse | |
import io | |
import json | |
import os | |
import sys | |
import time | |
import urllib | |
import urllib.parse | |
import zipfile | |
def main(): | |
parser = argparse.ArgumentParser(prog="cfpackdl") | |
parser.add_argument( | |
"-S", "--skip-on-error", | |
action="store_true", | |
help="Skip downloading a mod if it fails" | |
) | |
mode = parser.add_mutually_exclusive_group(required=True) | |
mode.add_argument("--url", help="Download and process modpack zip file") | |
mode.add_argument("--zip", help="Process a modpack zip you've already downloaded") | |
args = parser.parse_args() | |
def getZipReader(): | |
match [args.url, args.zip]: | |
case [url, None]: | |
if "curseforge.com/api/v1/mods" not in url: | |
print('Pack zip URL should look like "https://www.curseforge.com/api/v1/mods/..."') | |
print('(copy the manual download link, labeled "try again")') | |
raise SystemExit(1) | |
print("Downloading pack zip") | |
return fetch(url) | |
case [None, path]: | |
if not os.path.isfile(path): | |
print(f"Path {path} doesn't exist, or isn't a regular file") | |
raise SystemExit(1) | |
print(f"Reading pack from {path}") | |
return open(path, "rb") | |
case _: | |
assert False, "argparse should prevent passing both --url and --zip" | |
os.makedirs("mods", exist_ok = True) | |
anySkipped = False | |
with getZipReader() as rawZip: | |
rawZip = io.BytesIO(rawZip.read()) | |
with zipfile.ZipFile(rawZip) as zip: | |
print("Parsing manifest") | |
manifest = None | |
with zip.open("manifest.json", "r") as f: | |
manifest = json.load(f) | |
print("Downloading mods") | |
for file in manifest["files"]: | |
project = file["projectID"] | |
file = file["fileID"] | |
try: | |
filename = downloadJar(project, file) | |
print(f"=> downloaded {filename}") | |
except Exception as err: | |
if not args.skip_on_error: | |
raise err | |
print(f"=> skipped {filename} (could not download: {err})") | |
anySkipped = True | |
print("Unzipping config") | |
configs = (f for f in zip.namelist() if f.startswith("overrides/")) | |
for file in configs: | |
path = file.split("/", 1)[1] | |
dirname = os.path.dirname(path) | |
if dirname != "": | |
os.makedirs(os.path.dirname(path), exist_ok = True) | |
with zip.open(file, "r") as src: | |
with open(path, "wb") as dst: | |
pipeFile(src, dst) | |
print(f"=> unzipped {path}") | |
mcInfo = manifest["minecraft"] | |
print(f"\nMinecraft version {mcInfo['version']}") | |
print("Recommended modloader version(s):") | |
for loader in mcInfo["modLoaders"]: | |
print(f"* {loader['id']}") | |
print("\nDone!") | |
if anySkipped: | |
print("Warning: one or more files could not be downloaded!") | |
headers = { | |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; rv:117.0) Gecko/20100101 Firefox/117.0" | |
} | |
def fetch(url): | |
attempts = 5 | |
for attempt in range(attempts): | |
try: | |
req = request.Request(url, headers = headers) | |
return request.urlopen(req, timeout = 10) | |
except urllib.error.URLError as err: | |
match err: | |
case urllib.error.HTTPError(): | |
if err.code == 404: | |
raise err | |
case _: | |
match err.reason: | |
case TimeoutError(): | |
pass | |
case _: | |
raise err | |
print(f"retrying... ({err})") | |
time.sleep(1 + 2.5 * attempt) | |
raise TimeoutError(f"Fetching `{url}` timed out after {attempts} attempts") | |
def downloadJar(project, file): | |
url = f"https://www.curseforge.com/api/v1/mods/{project}/files/{file}/download" | |
with fetch(url) as resp: | |
(_, filename) = resp.url.rsplit("/", 1) | |
# expand percent escapes | |
filename = urllib.parse.parse_qsl(f"x={filename}")[0][1] | |
path = f"mods/{filename}" | |
if os.path.exists(path): | |
print("...already downloaded") | |
return filename | |
with open(f"mods/{filename}", "wb") as f: | |
pipeFile(resp, f) | |
return filename | |
pipeBuf = bytearray(64 * 1024) | |
def pipeFile(src, dst): | |
while True: | |
len = src.readinto(pipeBuf) | |
if len > 0: | |
dst.write(pipeBuf[:len]) | |
else: | |
break | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment