Files
lucaszhongsj--ps5-payloads-…/update_payloads.py
T
github-actions[bot] acd482b7de refactor: 以 repo_url 为单一身份,消除自拼接
审计修正:
- build_item 改为接收 repo_url 字符串而非 host/owner/repo 三参数;owner/repo 仅在
  调用 API 时内部 parse 一次。
- source 字段不再 f-string 拼 host/owner/repo,改用 release.html_url 推导 canonical
  repo URL + '/releases',redirect 后的别名(LightningMods/etaHEN → etaHEN/etaHEN)
  直接走 API 返回值。
- 所有 discovery/curate/enrich map 改用 lowercased repo_url 为键,废弃
  (host, owner_lower, repo_lower) 三元组。
- 移除 etaHEN .bin 特判死代码:唯一 asset 时 scoring 不影响结果。
- get_latest_release 直接吃 repo_url,parse 内化。
2026-07-05 22:52:41 +08:00

495 lines
18 KiB
Python

#!/usr/bin/env python3
"""Aggregate PS5 payload catalogue into payloads.json.
Three-layer composition, all read-only against upstream:
1. Discovery: `itsPLK/ps5-payloads-mirror/payloads.json` and
`phantomptr/ps5upload` CATALOGUE both supply upstream repo URLs.
2. Curation: `sources.json` overrides display name / description / asset
selection per repo, and can `"exclude": true` to suppress one.
3. Enrichment: `phantomptr/ps5upload` CATALOGUE provides longer
descriptions / display names where available.
Design rules:
- A repo is identified by its full HTML URL string (e.g.
`https://github.com/owner/repo`). All maps are keyed by the lowercased URL.
- The API response is the source of truth. Nothing is reassembled from
parsed fragments — `version`, `published_at`, asset `name` /
`browser_download_url` / `digest`, and the canonical repo URL (via
`release.html_url`) all come straight from the JSON the API returns.
- No binaries are downloaded. GitHub checksums come from the asset `digest`
field. Forgejo / missing-digest assets get an empty checksum.
- `/releases/latest` 404 → fall back to `/releases[0]` for pre-release-only
repos.
"""
import json
import re
import subprocess
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
SOURCES_FILE = "sources.json"
OUTPUT_FILE = "payloads.json"
README_FILE = "README.md"
ITSPLK_DISCOVERY_URL = (
"https://raw.githubusercontent.com/itsPLK/ps5-payloads-mirror/main/payloads.json"
)
PS5UPLOAD_CATALOGUE_URL = (
"https://raw.githubusercontent.com/phantomptr/ps5upload/main/"
"client/src-tauri/src/commands/payloads.rs"
)
CATALOGUE_NAME = "PS5 Payload Catalogue"
CST = timezone(timedelta(hours=8)) # UTC+8 / 东八区
# ─── URL helpers ─────────────────────────────────────────────────────
def normalize_repo_url(url: str) -> str:
"""Lowercase a repo HTML URL for use as a map key. Trims trailing slash and .git."""
if not url:
return ""
url = url.strip().rstrip("/")
if url.endswith(".git"):
url = url[:-4]
return url.lower()
def parse_repo_url(url: str):
"""Extract (host, owner, repo) from a repo HTML URL for API calls."""
m = re.search(r"https?://([^/]+)/([^/]+)/([^/?#]+)", url or "")
if not m:
return None
host, owner, repo = m.groups()
repo = repo.rstrip("/")
if repo.endswith(".git"):
repo = repo[:-4]
return host, owner, repo
def format_last_update(iso_str: str) -> str:
"""ISO 8601 UTC → 'YYYY-MM-DD HH:MM:SS UTC+8'."""
if not iso_str:
return ""
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S UTC+8")
except Exception:
return iso_str
# ─── HTTP / API ──────────────────────────────────────────────────────
def http_get(url: str, timeout: int = 30) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "ps5-payloads-atlas/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return response.read().decode("utf-8")
def gh_api(endpoint: str):
try:
result = subprocess.run(
["gh", "api", endpoint], capture_output=True, text=True, check=True
)
return json.loads(result.stdout) if result.stdout.strip() else None
except Exception as e:
print(f" gh api {endpoint} failed: {e}")
return None
def forgejo_api(host: str, endpoint: str):
try:
return json.loads(http_get(f"https://{host}/api/v1/{endpoint}"))
except Exception as e:
print(f" forgejo {host}/{endpoint} failed: {e}")
return None
def get_latest_release(repo_url: str) -> dict | None:
"""Fetch latest release (with pre-release fallback) for a repo URL."""
info = parse_repo_url(repo_url)
if not info:
return None
host, owner, repo = info
if host == "github.com":
release = gh_api(f"repos/{owner}/{repo}/releases/latest")
if release:
return release
releases = gh_api(f"repos/{owner}/{repo}/releases?per_page=1") or []
return releases[0] if releases else None
release = forgejo_api(host, f"repos/{owner}/{repo}/releases/latest")
if release and isinstance(release, dict):
return release
releases = forgejo_api(host, f"repos/{owner}/{repo}/releases?limit=1") or []
return releases[0] if releases else None
def canonical_repo_url(release: dict, fallback: str) -> str:
"""Use the API's release.html_url to derive the canonical repo URL.
`release.html_url` looks like
`https://github.com/<owner>/<repo>/releases/tag/<tag>` — strip everything
from `/releases` onward. Falls back to the input URL if absent.
"""
html_url = release.get("html_url") or ""
idx = html_url.find("/releases")
if idx > 0:
return html_url[:idx].lower()
return normalize_repo_url(fallback)
# ─── Asset selection ─────────────────────────────────────────────────
def score_asset(name: str, asset_pattern: str, has_extract: bool) -> float:
name_lower = name.lower()
if has_extract and name.endswith(".zip"):
return 20
if not (name.endswith(".elf") or name.endswith(".bin") or (has_extract and name.endswith(".zip"))):
return -1
if asset_pattern and not re.search(asset_pattern, name, re.IGNORECASE):
return -1
score = 0.0
if "ps5" in name_lower:
score += 10
if "ps4" in name_lower:
score -= 10
if "install" in name_lower:
score -= 5
score -= len(name) / 100.0
return score
def pick_asset(assets: list, asset_pattern: str, has_extract: bool) -> dict | None:
selected = None
best = -2.0
for asset in assets:
s = score_asset(asset["name"], asset_pattern, has_extract)
if s > best:
best = s
selected = asset
return selected if best > -1 else None
def get_checksum(asset: dict, host: str) -> str:
if host == "github.com":
digest = asset.get("digest") or ""
if digest.startswith("sha256:"):
return digest.split(":", 1)[1]
return ""
# ─── Category derivation ─────────────────────────────────────────────
CATEGORY_RULES = [
(r"kernel|kstuff|exploit|patch|jb|jailbreak", "Kernel"),
(r"\bftp\b|file transfer", "File Transfer"),
(r"\bhttp\b|web|telnet|\bdns\b", "Networking"),
(r"save", "Save Manager"),
(r"debug|gdb", "Debugger"),
(r"linux|loader|launch|homebrew|mount|backup|pkg", "Launcher"),
(r"cheat", "Misc"),
(r"controller|input|ghostpad|virtual pad", "Misc"),
(r"log|klog", "Networking"),
]
def derive_category(name: str, description: str) -> str:
text = f"{name} {description}".lower()
for pattern, category in CATEGORY_RULES:
if re.search(pattern, text):
return category
return "Misc"
# ─── Discovery: itsPLK payloads.json ────────────────────────────────
def fetch_itsplk_discovery() -> dict:
"""Return {repo_url_lower: {repo_url, display_name, description, asset_pattern, extract_file}}."""
try:
data = json.loads(http_get(ITSPLK_DISCOVERY_URL, timeout=30))
except Exception as e:
print(f"itsPLK discovery fetch failed (continuing from sources.json only): {e}")
return {}
discovered = {}
for p in data:
repo_url = p.get("source", "")
info = parse_repo_url(repo_url)
if not info:
continue
# `source` in itsPLK points at /releases; trim to the repo root URL.
repo_url = repo_url.split("/releases")[0].rstrip("/")
discovered[normalize_repo_url(repo_url)] = {
"repo_url": repo_url,
"display_name": p.get("name"),
"description": p.get("description") or "",
"asset_pattern": p.get("asset_pattern") or "",
"extract_file": p.get("extract_file"),
}
print(f"itsPLK discovery loaded: {len(discovered)} repos")
return discovered
# ─── Enrichment + discovery: ps5upload CATALOGUE ────────────────────
def strip_block_comments(text: str) -> str:
return re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
def extract_catalogue_block(text: str) -> str:
marker = "const CATALOGUE: &[CatalogueEntry] = &["
start = text.find(marker)
if start == -1:
return ""
start += len(marker)
struct_depth = 0
array_depth = 0
in_str = False
escape = False
for i, ch in enumerate(text[start:], start):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
struct_depth += 1
elif ch == "}":
struct_depth -= 1
elif ch == "[":
if struct_depth == 0:
array_depth += 1
elif ch == "]":
if struct_depth == 0:
if array_depth == 0:
return text[start:i]
array_depth -= 1
return ""
def split_catalogue_entries(block: str) -> list[str]:
entries = []
for m in re.finditer(r"CatalogueEntry\s*\{", block):
s = m.start()
depth = 0
in_str = False
escape = False
for i, ch in enumerate(block[s:], s):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
entries.append(block[s : i + 1])
break
return entries
def parse_catalogue_str(entry: str, key: str) -> str | None:
m = re.search(rf"\b{re.escape(key)}\s*:\s*\"([^\"]*)\"", entry)
return m.group(1) if m else None
def fetch_ps5upload_catalogue() -> dict:
"""Return {repo_url_lower: {repo_url, display_name, description, asset_name_hint}}.
ps5upload CATALOGUE is used both for discovery and for enrichment.
"""
try:
raw = strip_block_comments(http_get(PS5UPLOAD_CATALOGUE_URL, timeout=30))
except Exception as e:
print(f"ps5upload catalogue fetch failed (continuing without it): {e}")
return {}
block = extract_catalogue_block(raw)
if not block:
print("ps5upload CATALOGUE block not found; skipping")
return {}
catalogue = {}
for entry_text in split_catalogue_entries(block):
host = parse_catalogue_str(entry_text, "repo_host")
owner = parse_catalogue_str(entry_text, "repo_owner")
repo = parse_catalogue_str(entry_text, "repo_name")
if not (host and owner and repo):
continue
repo_url = f"https://{host}/{owner}/{repo}"
catalogue[normalize_repo_url(repo_url)] = {
"repo_url": repo_url,
"display_name": parse_catalogue_str(entry_text, "display_name"),
"description": parse_catalogue_str(entry_text, "description"),
"asset_name_hint": parse_catalogue_str(entry_text, "asset_name_hint") or "",
}
print(f"ps5upload catalogue loaded: {len(catalogue)} entries")
return catalogue
# ─── Item builder ────────────────────────────────────────────────────
def build_item(repo_url: str, override: dict, enrich: dict) -> dict | None:
"""Fetch release, pick asset, emit schema item. Returns item with `_canon` key."""
info = parse_repo_url(repo_url)
if not info:
return None
host = info[0]
asset_pattern = override.get("asset_pattern") or enrich.get("asset_name_hint") or ""
has_extract = bool(override.get("extract_file"))
print(f"Checking {repo_url}...")
release = get_latest_release(repo_url)
if not release:
print(" no release found, skipping")
return None
assets = release.get("assets", [])
if not assets:
print(f" no assets in release {release.get('tag_name')}, skipping")
return None
selected = pick_asset(assets, asset_pattern, has_extract)
if not selected:
print(f" no suitable asset, skipping")
return None
display_name = override.get("display_name") or enrich.get("display_name") or repo_url.rstrip("/").split("/")[-1]
description = override.get("description") or enrich.get("description") or ""
category = derive_category(display_name, description)
item = {
"name": display_name,
"filename": selected["name"],
"url": selected["browser_download_url"],
"description": description,
"version": release.get("tag_name", ""),
"category": category,
"checksum": get_checksum(selected, host),
"last_update": format_last_update(release.get("published_at") or ""),
# Source = repo URL (root), derived from release.html_url when available
# so redirect aliases (LightningMods/etaHEN → etaHEN/etaHEN) collapse.
"source": canonical_repo_url(release, repo_url) + "/releases",
"_canon": canonical_repo_url(release, repo_url),
}
print(f" → {selected['name']} @ {release.get('tag_name')} ({category})")
return item
# ─── README ──────────────────────────────────────────────────────────
def update_readme(payloads: list[dict]) -> None:
rows = [
"| Name | Version | Category | Description | Last Updated | Source |",
"| --- | --- | --- | --- | --- | --- |",
]
for p in payloads:
description = (p.get("description") or "No description provided.").replace("|", "\\|")
source_url = p.get("source", "")
m = re.search(r"https?://[^/]+/([^/]+)/([^/]+)/releases", source_url)
source_label = f"{m.group(1)}/{m.group(2)}" if m else "Source"
rows.append(
f"| **{p.get('name','')}** | `{p.get('version','')}` | {p.get('category','')} | "
f"{description} | `{p.get('last_update','')}` | [{source_label}]({source_url}) |"
)
table = "\n".join(rows)
start, end = "<!-- PAYLOADS_START -->", "<!-- PAYLOADS_END -->"
if not Path(README_FILE).exists():
return
with open(README_FILE, "r", encoding="utf-8") as f:
content = f.read()
if start in content and end in content:
pattern = re.compile(f"{re.escape(start)}.*?{re.escape(end)}", re.DOTALL)
content = pattern.sub(f"{start}\n{table}\n{end}", content)
with open(README_FILE, "w", encoding="utf-8") as f:
f.write(content)
print(f"Updated {README_FILE}")
# ─── Main ────────────────────────────────────────────────────────────
def main() -> None:
with open(SOURCES_FILE, "r", encoding="utf-8") as f:
sources = json.load(f)
print(f"Loaded {len(sources)} curated entries from {SOURCES_FILE}")
discovery = fetch_itsplk_discovery()
ps5upload = fetch_ps5upload_catalogue()
# Curation: overrides + excludes keyed by lowercased repo URL
overrides = {}
excludes = set()
for src in sources:
repo_url = src.get("url") or src.get("source", "")
if not repo_url:
continue
key = normalize_repo_url(repo_url)
overrides[key] = {**src, "repo_url": repo_url}
if src.get("exclude"):
excludes.add(key)
# Union: curated overrides + itsPLK discovery + ps5upload catalogue
seen = set(overrides.keys())
all_keys = list(overrides.keys())
for k in list(discovery.keys()) + list(ps5upload.keys()):
if k not in seen:
all_keys.append(k)
seen.add(k)
final_items = []
canon_seen = set()
skipped = []
for key in all_keys:
if key in excludes:
print(f"Excluded by sources.json: {key}")
continue
src_override = overrides.get(key, {})
disc = discovery.get(key, {})
psu = ps5upload.get(key, {})
repo_url = (
src_override.get("repo_url")
or disc.get("repo_url")
or psu.get("repo_url")
)
if not repo_url:
continue
# Merged override with priority: sources.json > ps5upload > itsPLK
override = {}
for field in ("display_name", "description", "asset_pattern", "extract_file"):
for src_data in (src_override, psu, disc):
val = src_data.get(field)
if val:
override[field] = val
break
item = build_item(repo_url, override, psu)
if not item:
skipped.append(repo_url)
continue
canon = item.pop("_canon")
if canon in canon_seen:
print(f" dedup: {repo_url} folds into {canon}, already listed")
continue
canon_seen.add(canon)
final_items.append(item)
final_items.sort(key=lambda p: p.get("name", "").lower())
document = {"name": CATALOGUE_NAME, "payloads": final_items}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(document, f, indent=2, ensure_ascii=False)
print(f"\nWrote {OUTPUT_FILE} ({len(final_items)} payloads, {len(skipped)} skipped)")
if skipped:
print(f"Skipped: {', '.join(skipped)}")
update_readme(final_items)
if __name__ == "__main__":
main()