Нема описа

update_app_store_metadata.py 9.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. #!/usr/bin/env python3
  2. """Push app-data/*.txt to App Store Connect via the official API."""
  3. from __future__ import annotations
  4. import json
  5. import os
  6. import sys
  7. import time
  8. from pathlib import Path
  9. import jwt
  10. import requests
  11. API_BASE = "https://api.appstoreconnect.apple.com/v1"
  12. ROOT = Path(__file__).resolve().parents[1]
  13. APP_DATA = ROOT / "app-data"
  14. ENV_PATH = ROOT / ".env"
  15. VERSION_STRING = os.environ.get("APP_STORE_VERSION", "1.0")
  16. LOCALE = os.environ.get("APP_STORE_LOCALE", "en-US")
  17. def load_env(path: Path) -> dict[str, str]:
  18. values: dict[str, str] = {}
  19. for line in path.read_text(encoding="utf-8").splitlines():
  20. line = line.strip()
  21. if not line or line.startswith("#") or "=" not in line:
  22. continue
  23. key, value = line.split("=", 1)
  24. values[key.strip()] = value.strip()
  25. return values
  26. def read_field(name: str) -> str:
  27. path = APP_DATA / name
  28. if not path.exists():
  29. raise FileNotFoundError(path)
  30. return path.read_text(encoding="utf-8").strip()
  31. class ASCClient:
  32. def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
  33. self.issuer_id = issuer_id
  34. self.key_id = key_id
  35. self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
  36. self.session = requests.Session()
  37. self._token: str | None = None
  38. self._token_exp = 0.0
  39. def _ensure_token(self) -> str:
  40. now = time.time()
  41. if self._token and now < self._token_exp - 60:
  42. return self._token
  43. issued_at = int(now)
  44. payload = {
  45. "iss": self.issuer_id,
  46. "iat": issued_at,
  47. "exp": issued_at + 1200,
  48. "aud": "appstoreconnect-v1",
  49. }
  50. self._token = jwt.encode(
  51. payload,
  52. self.private_key,
  53. algorithm="ES256",
  54. headers={"kid": self.key_id, "typ": "JWT"},
  55. )
  56. self._token_exp = issued_at + 1200
  57. return self._token
  58. def request(self, method: str, path: str, **kwargs) -> requests.Response:
  59. token = self._ensure_token()
  60. headers = kwargs.pop("headers", {})
  61. headers["Authorization"] = f"Bearer {token}"
  62. headers.setdefault("Content-Type", "application/json")
  63. url = path if path.startswith("http") else f"{API_BASE}{path}"
  64. response = self.session.request(method, url, headers=headers, timeout=60, **kwargs)
  65. return response
  66. def get_json(self, path: str, params: dict | None = None) -> dict:
  67. response = self.request("GET", path, params=params)
  68. if not response.ok:
  69. raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
  70. return response.json()
  71. def patch(self, resource_type: str, resource_id: str, attributes: dict) -> dict:
  72. body = {
  73. "data": {
  74. "type": resource_type,
  75. "id": resource_id,
  76. "attributes": attributes,
  77. }
  78. }
  79. response = self.request("PATCH", f"/{resource_type}/{resource_id}", json=body)
  80. if not response.ok:
  81. raise RuntimeError(
  82. f"PATCH {resource_type}/{resource_id} failed ({response.status_code}): {response.text}"
  83. )
  84. return response.json()
  85. def find_app(client: ASCClient, bundle_id: str) -> dict:
  86. data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
  87. apps = data.get("data", [])
  88. if not apps:
  89. raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
  90. return apps[0]
  91. def find_mac_app_info(client: ASCClient, app_id: str) -> dict:
  92. data = client.get_json(f"/apps/{app_id}/appInfos")
  93. for info in data.get("data", []):
  94. attrs = info.get("attributes", {})
  95. if attrs.get("appStoreState") is not None or attrs.get("brazilAgeRating") is not None:
  96. # Prefer MAC_OS if platform is listed in included relationships; filter by bundle platform.
  97. pass
  98. # App infos are per platform — pick MAC_OS via appStoreAgeRating or secondary query.
  99. for info in data.get("data", []):
  100. info_id = info["id"]
  101. detail = client.get_json(f"/appInfos/{info_id}")
  102. platform = detail.get("data", {}).get("attributes", {}).get("platform")
  103. if platform == "MAC_OS":
  104. return detail["data"]
  105. # Fallback: single app info entry.
  106. infos = data.get("data", [])
  107. if len(infos) == 1:
  108. return infos[0]
  109. raise RuntimeError("Could not determine MAC_OS appInfo; inspect /apps/{id}/appInfos")
  110. def find_app_info_localization(client: ASCClient, app_info_id: str, locale: str) -> dict:
  111. data = client.get_json(f"/appInfos/{app_info_id}/appInfoLocalizations")
  112. for loc in data.get("data", []):
  113. if loc.get("attributes", {}).get("locale") == locale:
  114. return loc
  115. raise RuntimeError(f"No appInfoLocalization for locale {locale!r}")
  116. def find_version(client: ASCClient, app_id: str, version_string: str) -> dict:
  117. data = client.get_json(
  118. f"/apps/{app_id}/appStoreVersions",
  119. params={"filter[platform]": "MAC_OS", "limit": 50},
  120. )
  121. matches = [
  122. v
  123. for v in data.get("data", [])
  124. if v.get("attributes", {}).get("versionString") == version_string
  125. ]
  126. if not matches:
  127. raise RuntimeError(f"No MAC_OS appStoreVersion {version_string!r}")
  128. # Prefer editable state if multiple.
  129. for v in matches:
  130. state = v.get("attributes", {}).get("appStoreState")
  131. if state in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"):
  132. return v
  133. return matches[0]
  134. def find_version_localization(client: ASCClient, version_id: str, locale: str) -> dict:
  135. data = client.get_json(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations")
  136. for loc in data.get("data", []):
  137. if loc.get("attributes", {}).get("locale") == locale:
  138. return loc
  139. raise RuntimeError(f"No appStoreVersionLocalization for locale {locale!r}")
  140. def main() -> int:
  141. if not ENV_PATH.exists():
  142. print(f"Missing {ENV_PATH}", file=sys.stderr)
  143. return 1
  144. env = load_env(ENV_PATH)
  145. issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
  146. key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
  147. key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
  148. bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
  149. for key, value in [
  150. ("APP_STORE_CONNECT_ISSUER_ID", issuer),
  151. ("APP_STORE_CONNECT_KEY_ID", key_id),
  152. ("APP_STORE_CONNECT_PRIVATE_KEY_PATH", key_path),
  153. ("APP_STORE_CONNECT_BUNDLE_ID", bundle_id),
  154. ]:
  155. if not value:
  156. print(f"Set {key} in {ENV_PATH}", file=sys.stderr)
  157. return 1
  158. client = ASCClient(issuer, key_id, key_path)
  159. app = find_app(client, bundle_id)
  160. app_id = app["id"]
  161. print(f"App: {app['attributes'].get('name')} ({app_id})")
  162. app_info = find_mac_app_info(client, app_id)
  163. app_info_id = app_info["id"]
  164. print(f"App Information (MAC_OS): {app_info_id}")
  165. info_loc = find_app_info_localization(client, app_info_id, LOCALE)
  166. info_loc_id = info_loc["id"]
  167. app_name = read_field("app-name.txt")
  168. subtitle = read_field("subtitle.txt")
  169. privacy_url = read_field("link-privacy.txt")
  170. info_patch = {
  171. "name": app_name,
  172. "subtitle": subtitle,
  173. "privacyPolicyUrl": privacy_url,
  174. }
  175. client.patch("appInfoLocalizations", info_loc_id, info_patch)
  176. print(f"Updated App Information localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
  177. version = find_version(client, app_id, VERSION_STRING)
  178. version_id = version["id"]
  179. version_state = version.get("attributes", {}).get("appStoreState")
  180. print(f"Version {VERSION_STRING}: {version_id} (state: {version_state})")
  181. copyright_text = read_field("copyright.txt")
  182. client.patch("appStoreVersions", version_id, {"copyright": copyright_text})
  183. print(f"Updated version copyright")
  184. version_loc = find_version_localization(client, version_id, LOCALE)
  185. version_loc_id = version_loc["id"]
  186. version_patch = {
  187. "description": read_field("description.txt"),
  188. "keywords": read_field("keywords.txt"),
  189. "promotionalText": read_field("promotional-text.txt"),
  190. "marketingUrl": read_field("link-marketing.txt"),
  191. "supportUrl": read_field("link-support.txt"),
  192. }
  193. client.patch("appStoreVersionLocalizations", version_loc_id, version_patch)
  194. print(f"Updated version {VERSION_STRING} localization ({LOCALE}): description, keywords, promotionalText, marketingUrl, supportUrl")
  195. # Custom EULA URL (Terms) — attach to app if not using standard Apple EULA.
  196. terms_url = read_field("link-terms.txt")
  197. try:
  198. eula_data = client.get_json(f"/apps/{app_id}/endUserLicenseAgreement")
  199. eula = eula_data.get("data")
  200. if eula:
  201. # Custom EULA resource uses agreementText, not URL in all cases; try app-level license.
  202. print("App has custom EULA resource; update Terms URL in App Store Connect if needed:", terms_url)
  203. else:
  204. print("Using standard Apple EULA; Terms URL for reference:", terms_url)
  205. except RuntimeError:
  206. print("EULA lookup skipped; Terms URL:", terms_url)
  207. print("Done.")
  208. return 0
  209. if __name__ == "__main__":
  210. raise SystemExit(main())