Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save SomeGuyWhoLovesCoding/f831c198d90dd5773ef1baa4832ec935 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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()
@SomeGuyWhoLovesCoding

Copy link
Copy Markdown
Author

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 ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment