Files
sotf-modloader/build.py
T

215 lines
6.9 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"
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 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
print(f"mod {mod_id} {version} ({len(files)} files)")
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"),
"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 main():
if OUT.exists():
shutil.rmtree(OUT)
shutil.copytree(SITE_SRC, OUT)
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,
"redloader": redloader,
"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()