Created
June 25, 2026 22:21
-
-
Save SomeGuyWhoLovesCoding/f831c198d90dd5773ef1baa4832ec935 to your computer and use it in GitHub Desktop.
(Generated with GLM 5.2) A python3 script to fetch all plotagon studio and education server assets ever
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Fetch Plotagon content catalogs and download every referenced thumbnail | |
| / icon / preview asset, so the app's scene picker, character picker, | |
| voice picker, store, etc. can run without contacting the Plotagon servers. | |
| Pulls all 6 content endpoints that app.js hits in getStoreContent (line 12299): | |
| /1.0/content/bundles | |
| /1.0/content/scenes | |
| /1.0/content/characters | |
| /1.0/content/character-items | |
| /1.0/content/voices | |
| /1.0/content/languages | |
| Tries both Story (app-api.plotagon.com) and Studio | |
| (plotagon-education-api.herokuapp.com) production endpoints. | |
| """ | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from urllib.parse import urlparse | |
| import requests | |
| OUT_DIR = "plotagon-scenes" | |
| CATALOG_PATH = os.path.join(OUT_DIR, "scenes_catalog.json") | |
| # (app_name, base_url, client_id, client_secret) | |
| ENDPOINTS = [ | |
| ( | |
| "story", | |
| "https://app-api.plotagon.com/api", | |
| "51d2a1633953551062000e5e", | |
| "213brffk404f0f2hk2tf2li3fv", | |
| ), | |
| ( | |
| "studio", | |
| "https://plotagon-education-api.herokuapp.com", | |
| "59490c61c129220011aebc63", | |
| "0913c1bcb6483f599934dd25edd21230", | |
| ), | |
| ] | |
| # (path, subdir, json_filename) | |
| # Order matches app.js getStoreContent (line 12299) | |
| CONTENT_PATHS = [ | |
| ("/1.0/content/bundles", "bundles", "bundles.json"), | |
| ("/1.0/content/scenes", "scenes", "scenes.json"), | |
| ("/1.0/content/characters", "characters", "characters.json"), | |
| ("/1.0/content/character-items", "character-items", "character-items.json"), | |
| ("/1.0/content/voices", "voices", "voices.json"), | |
| ("/1.0/content/languages", "languages", "languages.json"), | |
| ] | |
| # Fields that may contain a downloadable asset URL. | |
| IMAGE_FIELDS = ["thumbUrl", "icon", "headerForegroundUrl", "imageUrl", "previewImageUrl", "logoUrl"] | |
| AUDIO_FIELDS = ["previewUrl", "sampleUrl", "audioUrl", "previewAudioUrl"] | |
| # Required headers (see app.js sendRequestWithoutAuth ~line 11796) | |
| def headers_for(app_name, client_id, client_secret): | |
| return { | |
| "Accept": "application/json", | |
| "X-App-Name": app_name, | |
| "X-App-Version": "1.10.0", | |
| "X-Platform": "desktop", | |
| "X-Device": "extractor-pc", | |
| "X-OS": "Windows 10 x64", | |
| "X-Distinct-Id": "extractor-offline-0001", | |
| "X-Client-Id": client_id, | |
| "X-Client-Secret": client_secret, | |
| "X-Session-Id": "extractor-session-0001", | |
| "X-Content-Quality": "high", | |
| "X-Content-Flavor": "anyFlavor", | |
| "User-Agent": "PlotagonOfflineExtractor/1.0", | |
| } | |
| def safe_filename(url_or_id, fallback_ext=".png"): | |
| """Turn a URL or id into a safe filename.""" | |
| if url_or_id and url_or_id.startswith("http"): | |
| path = urlparse(url_or_id).path | |
| base = os.path.basename(path) | |
| if not base or base == "/": | |
| base = "asset_" + str(int(time.time())) + fallback_ext | |
| elif url_or_id: | |
| base = url_or_id | |
| else: | |
| base = "unknown" + fallback_ext | |
| return base.replace("/", "_").replace("\\", "_").replace(":", "_") | |
| def fetch_catalog(app_name, base_url, client_id, client_secret, path, session): | |
| """Fetch one content endpoint. Returns parsed JSON or None.""" | |
| url = base_url + path | |
| print(f"[{app_name}] GET {url}") | |
| r = session.get(url, headers=headers_for(app_name, client_id, client_secret), timeout=30) | |
| print(f"[{app_name}] -> HTTP {r.status_code} ({len(r.content)} bytes)") | |
| if r.status_code == 404: | |
| print(f"[{app_name}] endpoint not available, skipping") | |
| return None | |
| if r.status_code != 200: | |
| print(f"[{app_name}] body preview: {r.text[:300]}") | |
| return None | |
| try: | |
| return r.json() | |
| except Exception as e: | |
| print(f"[{app_name}] JSON parse error: {e}") | |
| return None | |
| def normalize_list(data): | |
| """Some endpoints (notably character-items and bundles) return a dict | |
| keyed by category with nested sub-dicts. Recursively flatten to a | |
| single list of item dicts. Mirrors the s() helper in app.js:12181.""" | |
| if isinstance(data, list): | |
| out = [] | |
| for item in data: | |
| if isinstance(item, dict): | |
| out.append(item) | |
| elif isinstance(item, list): | |
| out.extend(normalize_list(item)) | |
| return out | |
| if isinstance(data, dict): | |
| out = [] | |
| for k, v in data.items(): | |
| if isinstance(v, list): | |
| for item in v: | |
| if isinstance(item, dict): | |
| item.setdefault("_category", k) | |
| out.append(item) | |
| elif isinstance(item, list): | |
| out.extend(normalize_list(item)) | |
| elif isinstance(v, dict): | |
| for k2, v2 in v.items(): | |
| if isinstance(v2, list): | |
| for item in v2: | |
| if isinstance(item, dict): | |
| item.setdefault("_category", f"{k}/{k2}") | |
| out.append(item) | |
| return out | |
| return [] | |
| def download_image(url, dest_path, session, label="thumb"): | |
| """Download a single asset. Returns True on success.""" | |
| if not url: | |
| return False | |
| if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0: | |
| return True | |
| try: | |
| r = session.get(url, timeout=45) | |
| if r.status_code != 200: | |
| print(f" {label} FAIL HTTP {r.status_code}: {url[:80]}") | |
| return False | |
| if len(r.content) < 32: | |
| print(f" {label} WARN tiny response ({len(r.content)}B): {url[:80]}") | |
| with open(dest_path, "wb") as f: | |
| f.write(r.content) | |
| return True | |
| except Exception as e: | |
| print(f" {label} ERROR: {e} url={url[:80]}") | |
| return False | |
| def process_item(item, item_dir, session, label_prefix=""): | |
| """Walk one item dict, download every URL-like field, return a cleaned | |
| entry with localPath mappings. Preserves the full raw server response.""" | |
| if not isinstance(item, dict): | |
| return None | |
| sid = item.get("id") or item.get("sceneId") or item.get("_id") or item.get("productId") | |
| entry = { | |
| "id": sid, | |
| "title": item.get("title") or item.get("name"), | |
| "category": item.get("category") or item.get("_category"), | |
| "type": item.get("type"), | |
| "assets": {}, | |
| "raw": item, | |
| } | |
| for field in IMAGE_FIELDS + AUDIO_FIELDS: | |
| val = item.get(field) | |
| if isinstance(val, str) and val.startswith("http"): | |
| ext = ".png" | |
| if field in AUDIO_FIELDS or any(x in val.lower() for x in (".mp3", ".wav", ".ogg")): | |
| ext = ".mp3" | |
| fname = safe_filename(val, ext) | |
| # Prefix with item id to avoid collisions across items | |
| if sid: | |
| safe_id = safe_filename(sid, "") | |
| fname = f"{safe_id}__{fname}"[-180:] # cap filename length | |
| dest = os.path.join(item_dir, fname) | |
| label = f"{label_prefix}/{field}" | |
| if download_image(val, dest, session, label=label): | |
| entry["assets"][field] = { | |
| "url": val, | |
| "localPath": os.path.relpath(dest, OUT_DIR), | |
| } | |
| return entry | |
| def process_endpoint(app_name, base_url, client_id, client_secret, path, subdir, json_filename, session): | |
| """Fetch one endpoint, download all its assets, write its JSON.""" | |
| data = fetch_catalog(app_name, base_url, client_id, client_secret, path, session) | |
| if data is None: | |
| return None | |
| items = normalize_list(data) | |
| print(f"[{app_name}] {path} -> {len(items)} items") | |
| item_dir = os.path.join(OUT_DIR, app_name, subdir) | |
| os.makedirs(item_dir, exist_ok=True) | |
| cleaned = [] | |
| n_with_assets = 0 | |
| for item in items: | |
| c = process_item(item, item_dir, session, label_prefix=f"{app_name}/{subdir}") | |
| if c is not None: | |
| cleaned.append(c) | |
| if c["assets"]: | |
| n_with_assets += 1 | |
| out_json = os.path.join(OUT_DIR, app_name, json_filename) | |
| with open(out_json, "w", encoding="utf-8") as f: | |
| json.dump( | |
| { | |
| "endpoint": path, | |
| "app": app_name, | |
| "count": len(cleaned), | |
| "items": cleaned, | |
| }, | |
| f, ensure_ascii=False, indent=2, | |
| ) | |
| print(f"[{app_name}] wrote {out_json} ({len(cleaned)} items, {n_with_assets} with assets)") | |
| return {"path": path, "count": len(cleaned), "with_assets": n_with_assets, "file": out_json} | |
| def main(): | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| session = requests.Session() | |
| session.headers.update({"User-Agent": "PlotagonOfflineExtractor/1.0"}) | |
| summary = {app: [] for app, _, _, _ in ENDPOINTS} | |
| for app_name, base_url, cid, csec in ENDPOINTS: | |
| print(f"\n=== {app_name.upper()} ({base_url}) ===") | |
| for path, subdir, json_filename in CONTENT_PATHS: | |
| try: | |
| res = process_endpoint( | |
| app_name, base_url, cid, csec, path, subdir, json_filename, session | |
| ) | |
| if res: | |
| summary[app_name].append(res) | |
| except Exception as e: | |
| print(f"[{app_name}] {path} FAILED: {e}") | |
| time.sleep(0.4) # be polite | |
| with open(CATALOG_PATH, "w", encoding="utf-8") as f: | |
| json.dump(summary, f, indent=2) | |
| print("\n=== Summary ===") | |
| for app_name, results in summary.items(): | |
| total_items = sum(r["count"] for r in results) | |
| total_assets = sum(r["with_assets"] for r in results) | |
| print(f" {app_name}: {len(results)} endpoints, {total_items} items, {total_assets} with downloadable assets") | |
| print(f"\nIndex: {CATALOG_PATH}") | |
| print(f"Output root: {OUT_DIR}") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Just to let you know, this was based off what it read from app.js. App.js is what plotagon uses for nearly all of its ui under the hood, and I think it's pretty cool that they do that. It's even webkit which makes it more unique than ever. The first time discovering it was truly a big deal to me, and I can't believe I've just released this whole python script to you all.
Please run it while the server are still up ;)