Files
2026-09-28 15:05:19 -07:00

323 lines
11 KiB
Python

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")
print(f"done: {len(mods)} mods")
if __name__ == "__main__":
main()