Initial sotf-modloader site with mod install, update and remove
This commit is contained in:
commit
85ad3dde82
7 files changed
+1188
No files matched your search
@@ -0,0 +1,235 @@
|
||||
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"
|
||||
TOOL_TOPIC = "sotf-tool"
|
||||
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 build_tool(repo):
|
||||
if repo.get("homepage"):
|
||||
url = repo["homepage"]
|
||||
elif repo.get("has_pages"):
|
||||
url = f"https://{OWNER.lower()}.github.io/{repo['name']}/"
|
||||
else:
|
||||
url = repo["html_url"]
|
||||
print(f"tool {repo['name']}")
|
||||
return {
|
||||
"name": repo["name"],
|
||||
"description": repo.get("description") or "",
|
||||
"url": url,
|
||||
"repo": repo["html_url"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if OUT.exists():
|
||||
shutil.rmtree(OUT)
|
||||
shutil.copytree(SITE_SRC, OUT)
|
||||
mods = []
|
||||
tools = []
|
||||
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)
|
||||
elif TOOL_TOPIC in topics:
|
||||
tools.append(build_tool(repo))
|
||||
redloader = build_redloader()
|
||||
manifest = {
|
||||
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"owner": OWNER,
|
||||
"redloader": redloader,
|
||||
"mods": mods,
|
||||
"tools": tools,
|
||||
}
|
||||
(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, {len(tools)} tools")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user