2026-09-28 10:33:34 -07:00
|
|
|
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"
|
2026-09-28 14:24:42 -07:00
|
|
|
APP_REPO = os.environ.get("GITHUB_REPOSITORY") or f"{OWNER}/sotf-modloader"
|
|
|
|
|
APP_TAG_PREFIX = "app-v"
|
|
|
|
|
BUNDLE_NAME = "sotf-mods.zip"
|
2026-09-28 10:33:34 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 12:47:34 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 14:48:14 -07:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 10:33:34 -07:00
|
|
|
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
|
2026-09-28 12:47:34 -07:00
|
|
|
downloads = total_downloads(repo["full_name"])
|
2026-09-28 14:48:14 -07:00
|
|
|
command = install_command(repo)
|
|
|
|
|
print(f"mod {mod_id} {version} ({len(files)} files, {downloads} downloads, installer {'yes' if command else 'no'})")
|
2026-09-28 10:33:34 -07:00
|
|
|
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"),
|
2026-09-28 12:47:34 -07:00
|
|
|
"downloads": downloads,
|
2026-09-28 14:48:14 -07:00
|
|
|
"installCommand": command,
|
2026-09-28 10:33:34 -07:00
|
|
|
"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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 15:05:19 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 14:24:42 -07:00
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 10:33:34 -07:00
|
|
|
def main():
|
|
|
|
|
if OUT.exists():
|
|
|
|
|
shutil.rmtree(OUT)
|
|
|
|
|
shutil.copytree(SITE_SRC, OUT)
|
2026-09-28 14:48:14 -07:00
|
|
|
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")
|
2026-09-28 10:33:34 -07:00
|
|
|
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,
|
2026-09-28 14:24:42 -07:00
|
|
|
"app": build_app(),
|
|
|
|
|
"bundle": build_bundle(redloader, mods),
|
2026-09-28 10:33:34 -07:00
|
|
|
"redloader": redloader,
|
2026-09-28 15:05:19 -07:00
|
|
|
"redloaderVersions": build_redloader_versions(),
|
2026-09-28 10:33:34 -07:00
|
|
|
"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")
|
2026-09-28 10:33:34 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|