Skip to content

Instantly share code, notes, and snippets.

@Yoplitein
Last active March 30, 2025 03:50
Show Gist options
  • Save Yoplitein/e335e4f20775409af70d579f3dfa60da to your computer and use it in GitHub Desktop.
Save Yoplitein/e335e4f20775409af70d579f3dfa60da to your computer and use it in GitHub Desktop.
Script to download CurseForge modpacks
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