Created
November 7, 2025 15:42
-
-
Save Shukuyen/a83c7860d1e05bd4854910f858b33598 to your computer and use it in GitHub Desktop.
Pull screenshots from previous App releases from App Store Connect
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
| 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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Authentication
Create a
.envfile in the same folder as the script: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:Now you can go ahead and install the dependencies:
Running the script
Execute the script like this, to download screenshots of your app for a specific version from App Store Connect:
You can find a list of allowed display-types in Apple's documentation.
Example: