Skip to content

Instantly share code, notes, and snippets.

@Shukuyen
Created November 7, 2025 15:42
Show Gist options
  • Save Shukuyen/a83c7860d1e05bd4854910f858b33598 to your computer and use it in GitHub Desktop.
Save Shukuyen/a83c7860d1e05bd4854910f858b33598 to your computer and use it in GitHub Desktop.
Pull screenshots from previous App releases from App Store Connect
import argparse
import base64
import datetime as dt
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
import jwt # PyJWT
import requests
from dotenv import load_dotenv
ASC_API = "https://api.appstoreconnect.apple.com/v1"
VERBOSE = False
def make_jwt(key_id: str, issuer_id: str, private_key_pem: str, ttl_minutes: int = 5) -> str:
now = dt.datetime.now(dt.timezone.utc)
payload = {
"iss": issuer_id,
"exp": now + dt.timedelta(minutes=ttl_minutes),
"aud": "appstoreconnect-v1",
}
headers = {"kid": key_id, "alg": "ES256", "typ": "JWT"}
return jwt.encode(payload, private_key_pem, algorithm="ES256", headers=headers)
def asc_get(token: str, path: str, params: Optional[Dict] = None) -> Dict:
url = f"{ASC_API}{path}"
if VERBOSE:
print(f"[GET] {ASC_API}{path} params={params}")
r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, params=params or {})
if r.status_code >= 400:
# Try to surface Apple's JSON error payload (they usually return {"errors":[...]}).
try:
err = r.json()
except Exception:
err = r.text
raise requests.HTTPError(f"{r.status_code} {r.reason} for {url}\nParams={params}\nBody={err}", response=r)
return r.json()
def get_app_id(token: str, bundle_id: str) -> str:
data = asc_get(token, "/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
items = data.get("data", [])
if not items:
raise SystemExit(f"No app found for bundle id {bundle_id}")
return items[0]["id"]
def list_versions(token: str, app_id: str, platform: Optional[str]) -> List[Dict]:
base_path = f"/apps/{app_id}/appStoreVersions"
# first try without any sort; Apple doesn't guarantee sortability of versionString
params = {"limit": 200}
# Try platform filter if provided.
if platform:
try:
data = asc_get(token, base_path, params={**params, "filter[platform]": platform})
return data.get("data", [])
except requests.HTTPError as e:
print(f"[warn] platform filter rejected or caused error; retrying without it.\n{e}")
# Fallback: no platform filter
data = asc_get(token, base_path, params=params)
return data.get("data", [])
def list_localizations(token: str, version_id: str) -> List[Dict]:
data = asc_get(token, f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
params={"limit": 200})
return data.get("data", [])
def list_screenshot_sets(token: str, loc_id: str,
display_types: Optional[List[str]]) -> List[Dict]:
params = {"limit": 200}
# Optional: filter to certain display targets
if display_types:
# The API supports filtering by a single display type; if multiple are provided, we fetch all and filter client-side.
data = asc_get(token, f"/appStoreVersionLocalizations/{loc_id}/appScreenshotSets", params=params)
sets = data.get("data", [])
if not display_types:
return sets
return [s for s in sets if s["attributes"].get("screenshotDisplayType") in set(display_types)]
else:
data = asc_get(token, f"/appStoreVersionLocalizations/{loc_id}/appScreenshotSets", params=params)
return data.get("data", [])
def list_screenshots(token: str, set_id: str) -> List[Dict]:
# request fields for fileName and imageAsset so we can download
params = {
"limit": 200,
"fields[appScreenshots]": "fileName,imageAsset,uploadOperations,sourceFileChecksum,assetDeliveryState"
}
data = asc_get(token, f"/appScreenshotSets/{set_id}/appScreenshots", params=params)
return data.get("data", [])
def expand_template_url(image_asset: Dict) -> str:
"""
Apple returns an ImageAsset with width, height, and templateUrl like:
.../{w}x{h}bb.{f}
We fill in width/height and guess format from filename when possible.
"""
template = image_asset.get("templateUrl")
width = image_asset.get("width")
height = image_asset.get("height")
if not template or not width or not height:
raise ValueError("imageAsset missing templateUrl/width/height")
# Use PNG unless the filename clearly uses .jpg/.jpeg
fmt = "png"
return (template
.replace("{w}", str(width))
.replace("{h}", str(height))
.replace("{f}", fmt))
def download_file(url: str, dest: Path):
with requests.get(url, stream=True) as r:
r.raise_for_status()
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 15):
if chunk:
f.write(chunk)
def main():
load_dotenv()
parser = argparse.ArgumentParser(description="Download App Store Connect screenshots by version/locale/display type.")
parser.add_argument("--bundle-id", required=True, help="e.g., com.company.app")
parser.add_argument("--out", default="./asc_screenshots", help="Output directory")
parser.add_argument("--key-id", default=os.getenv("ASC_KEY_ID"))
parser.add_argument("--issuer-id", default=os.getenv("ASC_ISSUER_ID"))
parser.add_argument("--private-key-path", default=os.getenv("ASC_PRIVATE_KEY_PATH"))
parser.add_argument("--private-key", help="Private key PEM contents (overrides --private-key-path)")
parser.add_argument("--platform", choices=["IOS", "MAC_OS", "TV_OS"], default=None)
parser.add_argument("--version", help="Filter to a single version string (e.g., 3.7.0)")
parser.add_argument("--locales", nargs="*", help="Filter to locales (e.g., en-US de-DE)")
parser.add_argument("--display-types", nargs="*", help="Filter to display types (e.g., APP_IPHONE_65 APP_IPHONE_61)")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
global VERBOSE
VERBOSE = args.verbose
if not (args.key_id and args.issuer_id and (args.private_key or args.private_key_path)):
raise SystemExit("Missing ASC credentials. Provide --key-id, --issuer-id, and --private-key or --private-key-path")
private_key_pem = args.private_key or Path(args.private_key_path).read_text()
token = make_jwt(args.key_id, args.issuer_id, private_key_pem)
app_id = get_app_id(token, args.bundle_id)
versions = list_versions(token, app_id, args.platform)
# Optional version filter
if args.version:
versions = [v for v in versions if v["attributes"].get("versionString") == args.version]
if not versions:
raise SystemExit(f"No matching version '{args.version}' found for {args.bundle_id}")
out_root = Path(args.out)
for v in versions:
v_str = v["attributes"]["versionString"]
v_id = v["id"]
v_platform = v["attributes"].get("platform", "UNKNOWN")
print(f"\n=== Version {v_str} ({v_platform}) ===")
locs = list_localizations(token, v_id)
# Optional locale filter
if args.locales:
locs = [l for l in locs if l["attributes"].get("locale") in set(args.locales)]
if not locs:
print(" (no localizations)")
continue
for loc in locs:
locale = loc["attributes"]["locale"]
loc_id = loc["id"]
print(f" - {locale}")
sets = list_screenshot_sets(token, loc_id, args.display_types)
if not sets:
print(" (no screenshot sets)")
continue
for s in sets:
s_id = s["id"]
display_type = s["attributes"]["screenshotDisplayType"]
print(f" * {display_type}")
shots = list_screenshots(token, s_id)
if not shots:
print(" (no screenshots)")
continue
for idx, shot in enumerate(shots, start=1):
attrs = shot["attributes"]
file_name = attrs.get("fileName") or f"screenshot_{idx}.png"
image_asset = attrs.get("imageAsset")
if not image_asset:
print(" - missing imageAsset; skipping")
continue
try:
url = expand_template_url(image_asset)
except Exception as e:
print(f" - cannot expand template: {e}")
continue
# Build path: out/{bundleId}/{version}/{locale}/{displayType}/NNN_filename
safe_display = display_type.replace("/", "_")
dest_dir = out_root / args.bundle_id / v_str / locale / safe_display
# Prefix with 3-digit index to preserve order
dest_path = dest_dir / f"{idx:03d}_{file_name}"
try:
download_file(url, dest_path)
print(f" - saved {dest_path}")
except requests.HTTPError as e:
print(f" - HTTP {e.response.status_code} fetching image; url redacted")
except Exception as e:
print(f" - error: {e}")
if __name__ == "__main__":
main()
@Shukuyen
Copy link
Author

Shukuyen commented Nov 7, 2025

Authentication

Create a .env file in the same folder as the script:

ASC_KEY_ID=<apple api key id>
ASC_ISSUER_ID=<apple api issuer id>
ASC_PRIVATE_KEY_PATH=<absolute path to your apple api key file>

You can get all required information by logging in to App Store Connect and creating a team api key here: https://appstoreconnect.apple.com/access/integrations/api

Dependencies

The script requires some libraries that you need to install with pip. If you, like me, installed pyhton3 with homebrew on a Mac, you need to create a virtual environment in the folder with the script for python first:

python3 -m venv .venv
source venv/bin/activate   # macOS / Linux
# or
venv\Scripts\activate      # Windows

Now you can go ahead and install the dependencies:

python3 -m pip install PyJWT requests dotenv cryptography

Running the script

Execute the script like this, to download screenshots of your app for a specific version from App Store Connect:

python3 pull_asc_screenshots.py \
  --bundle-id <bundle id of the app> \
  --out <path to the folder to put the screenshots in> \
  --platform IOS \
  --version <app version name>  \
  --locales <list of locales to fetch> \
  --display-types <display sizes to download images in>

You can find a list of allowed display-types in Apple's documentation.

Example:

python3 pull_asc_screenshots.py \
  --bundle-id com.yourcompany.yourapp \
  --out ./screenshots_archive \
  --platform IOS \
  --version 3.7.0 \
  --locales en-US de-DE \
  --display-types APP_IPHONE_65 APP_IPHONE_61

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