Files
sotf-modloader/build.py
T

323 lines
11 KiB
Python
Raw Normal View History

import io
import json
import os
import shutil
import urllib.error
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
API = "https://api.github.com"
OWNER = os.environ.get("OWNER", "JustinOros")
TOKEN = os.environ.get("GITHUB_TOKEN", "")
REDLOADER_REPO = "ToniMacaroni/RedLoader"
MOD_TOPIC = "sotf-mod"
APP_REPO = os.environ.get("GITHUB_REPOSITORY") or f"{OWNER}/sotf-modloader"
APP_TAG_PREFIX = "app-v"
BUNDLE_NAME = "sotf-mods.zip"
ROOT = Path(__file__).resolve().parent
SITE_SRC = ROOT / "site"
OUT = ROOT / "_site"
def request(url, accept="application/vnd.github+json"):
headers = {"Accept": accept, "User-Agent": "sotf-modloader-build"}
if TOKEN and url.startswith(API):
headers["Authorization"] = f"Bearer {TOKEN}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=300) as resp:
return resp.read()
def api(path):
return json.loads(request(API + path))
def list_repos():
repos = []
page = 1
while True:
batch = api(f"/users/{OWNER}/repos?per_page=100&page={page}&type=owner")
if not batch:
break
repos.extend(batch)
page += 1
return [r for r in repos if not r.get("archived")]
def latest_release(full_name):
try:
return api(f"/repos/{full_name}/releases/latest")
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def total_downloads(full_name):
total = 0
page = 1
while True:
batch = api(f"/repos/{full_name}/releases?per_page=100&page={page}")
if not batch:
break
for release in batch:
for asset in release.get("assets", []):
total += asset.get("download_count", 0)
page += 1
return total
def install_command(repo):
branch = repo.get("default_branch") or "main"
url = f"https://raw.githubusercontent.com/{repo['full_name']}/{branch}/Install.ps1"
try:
request(url, accept="text/plain")
except (urllib.error.URLError, OSError):
return None
return f"irm {url} | iex"
def zip_asset(release):
for asset in release.get("assets", []):
if asset["name"].lower().endswith(".zip"):
return asset
return None
def slug(value):
return "".join(c if c.isalnum() or c in "._-" else "-" for c in str(value)) or "x"
def safe_parts(name):
parts = [p for p in name.replace("\\", "/").split("/") if p not in ("", ".")]
if not parts or any(p == ".." or ":" in p for p in parts):
return None
return parts
def file_entries(zf):
for info in zf.infolist():
if info.is_dir() or info.filename.endswith(("/", "\\")):
continue
parts = safe_parts(info.filename)
if parts:
yield info, parts
def extract(data, dest, prefix=()):
files = []
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info, parts in file_entries(zf):
rel = PurePosixPath(*prefix, *parts)
target = dest.joinpath(*rel.parts)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zf.read(info))
files.append({"path": str(rel), "size": info.file_size})
files.sort(key=lambda f: f["path"].lower())
return files
def inspect_mod_zip(data):
meta = {}
tops = set()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info, parts in file_entries(zf):
tops.add(parts[0].lower())
if not meta and parts[-1].lower() == "manifest.json" and len(parts) >= 2:
try:
meta = json.loads(zf.read(info).decode("utf-8-sig"))
except ValueError:
meta = {}
prefix = () if tops == {"mods"} else ("Mods",)
return meta, prefix
def mod_layout(files):
paths = [f["path"] for f in files]
dll = next((p for p in paths if p.count("/") == 1 and p.lower().endswith(".dll")), None)
manifest_path = next((p for p in paths if p.count("/") == 2 and p.lower().endswith("/manifest.json")), None)
roots = sorted({"/".join(p.split("/")[:2]) for p in paths}, key=str.lower)
return dll, manifest_path, roots
def build_mod(repo):
release = latest_release(repo["full_name"])
if not release:
print(f"skip {repo['name']}: no release")
return None
asset = zip_asset(release)
if not asset:
print(f"skip {repo['name']}: latest release has no zip")
return None
data = request(asset["browser_download_url"], accept="application/octet-stream")
meta, prefix = inspect_mod_zip(data)
tag_version = release["tag_name"].lstrip("vV")
mod_id = meta.get("id") or repo["name"].removeprefix("sotf-")
version = meta.get("version") or tag_version
if version != tag_version:
print(f"warning {repo['name']}: manifest.json version {version} does not match tag {release['tag_name']}")
base = PurePosixPath("packages", slug(mod_id), slug(version))
files = extract(data, OUT.joinpath(*base.parts), prefix)
dll, manifest_path, roots = mod_layout(files)
if not dll:
print(f"skip {repo['name']}: no DLL at the top of the zip")
shutil.rmtree(OUT.joinpath(*base.parts), ignore_errors=True)
return None
downloads = total_downloads(repo["full_name"])
command = install_command(repo)
print(f"mod {mod_id} {version} ({len(files)} files, {downloads} downloads, installer {'yes' if command else 'no'})")
return {
"id": mod_id,
"name": meta.get("name") or mod_id,
"description": meta.get("description") or repo.get("description") or "",
"author": meta.get("author") or OWNER,
"version": version,
"tag": release["tag_name"],
"platform": meta.get("platform") or "Client",
"gameVersion": meta.get("gameVersion") or "",
"repo": repo["html_url"],
"releaseUrl": release["html_url"],
"downloadUrl": asset["browser_download_url"],
"published": release.get("published_at"),
"downloads": downloads,
"installCommand": command,
"base": f"{base}/",
"dll": dll,
"manifestPath": manifest_path,
"roots": roots,
"files": files,
}
def build_redloader():
try:
release = latest_release(REDLOADER_REPO)
asset = zip_asset(release) if release else None
if not asset:
print("warning: RedLoader release zip not found")
return None
data = request(asset["browser_download_url"], accept="application/octet-stream")
except (urllib.error.URLError, OSError) as e:
print(f"warning: RedLoader download failed: {e}")
return None
version = release["tag_name"]
base = PurePosixPath("packages", "redloader", slug(version))
files = extract(data, OUT.joinpath(*base.parts))
print(f"RedLoader {version} ({len(files)} files)")
return {
"name": "RedLoader",
"version": version,
"repo": f"https://github.com/{REDLOADER_REPO}",
"releaseUrl": release["html_url"],
"downloadUrl": asset["browser_download_url"],
"license": "Apache-2.0",
"base": f"{base}/",
"files": files,
}
def build_redloader_versions(limit=10):
try:
releases = api(f"/repos/{REDLOADER_REPO}/releases?per_page=30")
except (urllib.error.URLError, OSError) as e:
print(f"warning: could not list RedLoader releases: {e}")
return []
versions = []
for release in releases:
if release.get("draft") or release.get("prerelease"):
continue
asset = zip_asset(release)
if not asset:
continue
versions.append({
"version": release["tag_name"],
"downloadUrl": asset["browser_download_url"],
"releaseUrl": release["html_url"],
"size": asset.get("size", 0),
})
if len(versions) >= limit:
break
print(f"RedLoader versions: {', '.join(v['version'] for v in versions)}")
return versions
def build_app():
try:
releases = api(f"/repos/{APP_REPO}/releases?per_page=30")
except urllib.error.HTTPError as e:
print(f"warning: could not list app releases: {e}")
return None
for release in releases:
tag = release.get("tag_name", "")
if release.get("draft") or release.get("prerelease") or not tag.startswith(APP_TAG_PREFIX):
continue
for asset in release.get("assets", []):
if asset["name"].lower().endswith(".exe"):
print(f"app {tag}")
return {
"version": tag[len(APP_TAG_PREFIX):],
"url": asset["browser_download_url"],
"size": asset.get("size", 0),
"releaseUrl": release["html_url"],
}
print("warning: no app release found")
return None
def build_bundle(redloader, mods):
target = OUT / "downloads" / BUNDLE_NAME
target.parent.mkdir(parents=True, exist_ok=True)
seen = set()
packages = ([redloader] if redloader else []) + mods
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
for pkg in packages:
src = OUT.joinpath(*PurePosixPath(pkg["base"]).parts)
for f in pkg["files"]:
key = f["path"].lower()
if key in seen:
continue
seen.add(key)
zf.write(src.joinpath(*PurePosixPath(f["path"]).parts), f["path"])
if redloader:
zf.writestr("sotf-modloader.json", json.dumps({"redloader": redloader["version"]}, indent=2))
size = target.stat().st_size
print(f"bundle {BUNDLE_NAME} ({size} bytes)")
return {"url": f"downloads/{BUNDLE_NAME}", "size": size}
def main():
if OUT.exists():
shutil.rmtree(OUT)
shutil.copytree(SITE_SRC, OUT)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
index = OUT / "index.html"
html = index.read_text(encoding="utf-8")
html = html.replace('src="app.js"', f'src="app.js?v={stamp}"').replace('href="style.css"', f'href="style.css?v={stamp}"')
index.write_text(html, encoding="utf-8")
mods = []
for repo in sorted(list_repos(), key=lambda r: r["name"].lower()):
topics = repo.get("topics") or []
if MOD_TOPIC in topics:
entry = build_mod(repo)
if entry:
mods.append(entry)
redloader = build_redloader()
manifest = {
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"owner": OWNER,
"app": build_app(),
"bundle": build_bundle(redloader, mods),
"redloader": redloader,
"redloaderVersions": build_redloader_versions(),
"mods": mods,
}
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
(OUT / ".nojekyll").write_text("", encoding="utf-8")
2026-09-28 10:42:20 -07:00
print(f"done: {len(mods)} mods")
if __name__ == "__main__":
main()