自动化 App Store Connect 多语言发版更新说明上传

37 阅读16分钟

用 Python 自动化 App Store Connect 多语言发版说明上传

每次发版只需改一个 YAML 文件、跑一条命令,9 种语言的「What's New」自动翻译并同步到 App Store Connect。


目录

  1. 背景与痛点

  2. 方案概览

  3. 技术架构

  4. 项目结构

  5. 环境准备与首次配置

  6. 日常使用流程

  7. 配置文件详解

  8. 核心实现解析

  9. 完整源码

  10. 常见问题与排查

  11. 安全建议

  12. 扩展与定制


背景与痛点

iOS 应用上架或更新时,需要在 App Store Connect 为每一种语言单独填写「此版本的新增内容」(What's New)。对于支持多语言的应用,这通常意味着:

  • 在网页上逐个语言切换、复制粘贴

  • 手动翻译或借助 ChatGPT 逐条处理

  • 容易漏语言、版本号对不上、格式不统一

本工具将这一流程压缩为:


编辑 release_notes.yaml → 运行 python3 scripts/upload_notes.py → 完成

脚本会自动完成:读取源语言文案 → 翻译其余语言 → 生成 JWT → 调用 App Store Connect API 批量上传


方案概览

| 能力 | 说明 |

|------|------|

| 源语言可配置 | 默认 zh-Hans,也可改为 en-US 等任意已配置语言 |

| 自动翻译 | 优先使用 Google 翻译(免费、无需 API Key) |

| 翻译降级 | Google 失败时自动回退 OpenAI GPT-4o-mini(可选) |

| 手动覆盖 | YAML 中某语言已填写内容时,跳过自动翻译 |

| 智能版本匹配 | 自动找到 App Store Connect 中最新可编辑的版本 |

| 本地化增改 | 已有语言记录则 PATCH 更新,否则 POST 新建 |

| 结果预览 | 生成 release_notes_preview_x_x_x.txt 供人工复核 |

**支持的语言(与 App Store Connect locale 一致): **

| 语言代码 | 说明 |

|---------|------|

| en-US | 英语(美国) |

| zh-Hans | 简体中文 |

| zh-Hant | 繁体中文 |

| de-DE | 德语 |

| fr-FR | 法语 |

| es-ES | 西班牙语 |

| pt-BR | 葡萄牙语(巴西) |

| it | 意大利语 |

| tr | 土耳其语 |


技术架构


flowchart LR

    A[release_notes.yaml] --> B[upload_notes.py]

    B --> C{需要翻译?}

    C -->|是| D[Google 翻译]

    C -->|否| E[使用原文/手动文案]

    D -->|失败| F[OpenAI 备用]

    D --> E

    F --> E

    E --> G[生成 ASC JWT]

    G --> H[App Store Connect API]

    H --> I[创建/更新 Version Localization]

    E --> J[release_notes_preview.txt]

**技术栈: **

  • Python 3 — 脚本运行时

  • PyJWT + ES256 — App Store Connect API 鉴权

  • httpx — HTTP 客户端

  • PyYAML — 读取发版说明配置

  • python-dotenv — 环境变量管理

  • deep-translator — Google 翻译封装


项目结构


scripts/

├── upload_notes.py              # 主脚本

├── release_notes.yaml           # 发版说明配置(每次发版只改这个)

├── requirements.txt             # Python 依赖

├── .env.example                 # 环境变量模板

├── .env                         # 实际配置(勿提交 git)

├── AuthKey_XXXXXXXXXX.p8        # ASC API 私钥(勿提交 git)

├── release_notes_preview_*.txt  # 运行后生成的翻译预览

└── README.md                    # 快速使用说明


环境准备与首次配置

1. 安装 Python 依赖

项目根目录执行(macOS 请使用 python3 / pip3):


pip3 install -r scripts/requirements.txt

requirements.txt 内容:


PyJWT>=2.8.0

httpx>=0.27.0

pyyaml>=6.0.1

python-dotenv>=1.0.0

cryptography>=41.0.0

deep-translator>=1.11.4

2. 获取 App Store Connect API 密钥

  1. 打开 App Store Connect用户与访问集成App Store Connect API

  2. 点击 ** + ** 生成新密钥,权限选择 App 管理(App Manager)

  3. 下载 .p8 私钥文件(只能下载一次,请妥善保存)

  4. .p8 文件放到 scripts/ 目录

  5. 记录页面上的 Key IDIssuer ID

3. 获取应用的 Apple ID

在 App Store Connect → 选择你的 App → App 信息 → 找到 Apple ID(纯数字,例如 1234567890)。

4. 配置环境变量


cp scripts/.env.example scripts/.env

编辑 scripts/.env,填入真实值:


# App Store Connect API(必填)

ASC_KEY_ID=ABCD1234EF

ASC_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a

ASC_PRIVATE_KEY=scripts/AuthKey_XXXXXXXXXX.p8

ASC_APP_ID=1234567890

  


# OpenAI API Key(可选,Google 翻译失败时的备用)

# OPENAI_API_KEY=sk-xxxxxxxx

注意:脚本从 scripts/.env 加载环境变量(与 upload_notes.py 同目录),请确保 .env 放在 scripts/ 下。

5. 在 App Store Connect 创建新版本

脚本只能向已存在且可编辑的版本写入发版说明。请先在 ASC 网页端:

  1. 进入 App → App Store 标签

  2. 点击 ** + ** 创建新版本,填写版本号(需与 release_notes.yaml 中的 version 一致或接近)

  3. 确保版本状态为 Prepare for Submission(准备提交)或类似可编辑状态


日常使用流程

第一步:编辑发版说明

打开 scripts/release_notes.yaml,修改 version 和源语言内容。其余语言留空 "" 即可自动翻译。

**示例 A:以简体中文为源语言(默认) **


version: "2.1.0"

  


source_language: "zh-Hans"

  


notes:

  zh-Hans: |

     新增每日互动功能

     优化 AI 响应速度

     修复若干已知问题

  en-US: ""

  zh-Hant: ""

  de-DE: ""

  fr-FR: ""

  es-ES: ""

  pt-BR: ""

  it: ""

  tr: ""

示例 B:以英文为源语言


version: "1.10.41"

  


source_language: "en-US"

  


notes:

  en-US: |

    What's New:

    Added Tai Chi courses.

    Added landscape video playback support.

    Fixed bugs and improved user experience.

  zh-Hans: ""

  zh-Hant: ""

  de-DE: ""

  fr-FR: ""

  es-ES: ""

  pt-BR: ""

  it: ""

  tr: ""

如需某语言使用人工精修文案,直接填写该语言字段,脚本会跳过翻译。

第二步:运行脚本

项目根目录执行:


python3 scripts/upload_notes.py

第三步:检查输出

成功时终端会显示类似:


=======================================================

  App Store Connect 多语言发版说明上传工具

=======================================================

  


① 读取配置

  ✓ 版本:1.10.41

  ✓ 源语言:en-US

  ✓ 目标语言数:9

  


② 自动翻译(Google 翻译)

  ✓ en-US  (原文,跳过翻译)

  → zh-Hans  翻译中...

  ✓ zh-Hans  翻译完成

  ...

  


③ 连接 App Store Connect

  ✓ JWT Token 生成成功

  ✓ 找到版本:1.10.41  状态:PREPARE_FOR_SUBMISSION

  


④ 上传更新说明

  ✓ en-US  已更新

  ✓ zh-Hans  已更新

  ...

  


⑤ 翻译结果预览

  ✓ 预览文件已保存:scripts/release_notes_preview_1_10_41.txt

  


=======================================================

  全部完成!成功上传 9 个语言

=======================================================

打开 scripts/release_notes_preview_1_10_41.txt 复核各语言翻译质量,必要时在 YAML 中手动覆盖后重新运行。


配置文件详解

release_notes.yaml 字段说明

| 字段 | 类型 | 说明 |

|------|------|------|

| version | string | 版本号字符串,用于预览文件命名,建议与 ASC 版本一致 |

| source_language | string | 源语言 locale,翻译从此语言出发 |

| notes | object | 各语言发版说明,key 为 locale,value 为多行文本 |

****** **notes** 中每种语言的三种状态: **

  1. 源语言 — 必须填写,作为翻译来源

  2. 留空 **"" ** — 脚本自动翻译

  3. 已填写 — 视为人工定稿,跳过翻译

环境变量说明

| 变量 | 必填 | 说明 |

|------|------|------|

| ASC_KEY_ID | 是 | API 密钥 Key ID |

| ASC_ISSUER_ID | 是 | Issuer ID |

| ASC_PRIVATE_KEY | 是 | .p8 私钥文件路径 |

| ASC_APP_ID | 是 | 应用 Apple ID(数字) |

| OPENAI_API_KEY | 否 | OpenAI 密钥,翻译降级用 |


核心实现解析

1. App Store Connect JWT 鉴权

Apple API 使用 ES256 签名的 JWT,有效期 20 分钟:


payload = {

    "iss": issuer_id,

    "iat": int(time.time()),

    "exp": int(time.time()) + 1200,

    "aud": "appstoreconnect-v1",

}

token = jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})

2. 版本查找策略

调用 GET /v1/apps/{app_id}/appStoreVersions 获取最近 10 个版本,客户端按版本号降序排序,选取第一个处于可编辑状态的版本:

  • PREPARE_FOR_SUBMISSION

  • DEVELOPER_REJECTED / REJECTED / METADATA_REJECTED

  • WAITING_FOR_REVIEW / READY_FOR_REVIEW

若最新版本已上架(如 READY_FOR_SALE),脚本会报错提示先创建新版本。

3. 本地化记录的创建与更新


GET  /v1/appStoreVersions/{id}/appStoreVersionLocalizations  → 获取已有记录

PATCH /v1/appStoreVersionLocalizations/{id}                  → 更新 whatsNew

POST /v1/appStoreVersionLocalizations                        → 新建记录

4. 翻译引擎选择


def translate(text, target_locale, source_locale="zh-Hans"):

    try:

        return translate_google(text, target_locale, source_locale)

    except Exception:

        return translate_openai(text, target_locale, source_locale)

Google 翻译通过 deep-translator 库调用,无需 API Key;OpenAI 使用 gpt-4o-mini,system prompt 要求保留 bullet 格式、输出简洁自然的 App Store 文案。

5. Locale 映射

YAML 中的 key 与 ASC API locale 通过 LOCALE_MAP 映射;Google 翻译使用独立的 GOOGLE_LANG_MAP(如 zh-Hanszh-CN)。


完整源码

scripts/upload_notes.py


#!/usr/bin/env python3

"""

App Store Connect 多语言发版说明自动上传工具

=========================================

功能:

  1. 读取 release_notes.yaml 中的中文更新说明

  2. 自动翻译为所有目标语言(Google 翻译,免费无需 API Key)

  3. 调用 App Store Connect API 批量上传

  


使用方法:

  python scripts/upload_notes.py

  


依赖安装:

  pip install -r scripts/requirements.txt

  


环境变量(在 .env 文件中配置):

  ASC_KEY_ID         - App Store Connect API Key ID

  ASC_ISSUER_ID      - App Store Connect Issuer ID

  ASC_PRIVATE_KEY    - .p8 私钥文件路径(如 scripts/asc_key.p8)

  ASC_APP_ID         - 应用的 Apple ID(数字)

  OPENAI_API_KEY     - OpenAI API Key(可选,Google 翻译失败时的备用引擎)

"""

  


import os

import sys

import time

import yaml

import jwt

import httpx

from datetime import datetime

from pathlib import Path

from dotenv import load_dotenv

from deep_translator import GoogleTranslator

  


# ── 加载 .env ──────────────────────────────────────────────────────────────────

SCRIPT_DIR = Path(__file__).parent

load_dotenv(SCRIPT_DIR / ".env")

  


# ── App Store Connect 语言代码映射 ─────────────────────────────────────────────

# 左侧:release_notes.yaml 中的 key;右侧:ASC API 实际接受的 locale

LOCALE_MAP = {

    "en-US":    "en-US",

    "zh-Hans""zh-Hans",

    "zh-Hant""zh-Hant",

    "de-DE":    "de-DE",

    "fr-FR":    "fr-FR",

    "es-ES":    "es-ES",

    "pt-BR":    "pt-BR",

    "it":       "it",

    "tr":       "tr",

}

  


# Google 翻译语言代码映射

GOOGLE_LANG_MAP = {

    "en-US":    "en",

    "zh-Hans""zh-CN",

    "zh-Hant""zh-TW",

    "de-DE":    "de",

    "fr-FR":    "fr",

    "es-ES":    "es",

    "pt-BR":    "pt",

    "it":       "it",

    "tr":       "tr",

}

  


# OpenAI 语言名称映射(备用)

OPENAI_LANG_MAP = {

    "en-US":    "American English",

    "zh-Hans""Simplified Chinese",

    "zh-Hant""Traditional Chinese",

    "de-DE":    "German",

    "fr-FR":    "French",

    "es-ES":    "Spanish",

    "pt-BR":    "Brazilian Portuguese",

    "it":       "Italian",

    "tr":       "Turkish",

}

  


  


# ── 颜色输出 ───────────────────────────────────────────────────────────────────

class C:

    RESET  = "\033[0m"

    GREEN  = "\033[92m"

    YELLOW = "\033[93m"

    RED    = "\033[91m"

    CYAN   = "\033[96m"

    BOLD   = "\033[1m"

  


def info(msg):  print(f"  {C.CYAN}{C.RESET} {msg}")

def ok(msg):    print(f"  {C.GREEN}{C.RESET} {msg}")

def warn(msg):  print(f"  {C.YELLOW}{C.RESET} {msg}")

def err(msg):   print(f"  {C.RED}{C.RESET} {msg}")

def section(title): print(f"\n{C.BOLD}{title}{C.RESET}")

  


  


# ── App Store Connect JWT ──────────────────────────────────────────────────────

def generate_asc_token() -> str:

    key_id    = os.environ["ASC_KEY_ID"]

    issuer_id = os.environ["ASC_ISSUER_ID"]

    key_path  = Path(os.environ["ASC_PRIVATE_KEY"]).expanduser()

  


    if not key_path.exists():

        raise FileNotFoundError(f"找不到 .p8 私钥文件:{key_path}")

  


    private_key = key_path.read_text()

  


    payload = {

        "iss": issuer_id,

        "iat": int(time.time()),

        "exp": int(time.time()) + 1200# 20 分钟有效期

        "aud": "appstoreconnect-v1",

    }

  


    token = jwt.encode(

        payload,

        private_key,

        algorithm="ES256",

        headers={"kid": key_id},

    )

    return token

  


  


# ── 翻译:Google 翻译(免费,无需 API Key)──────────────────────────────────────

def translate_google(text: str, target_locale: str, source_locale: str) -> str:

    source_lang = GOOGLE_LANG_MAP.get(source_locale, "zh-CN")

    target_lang = GOOGLE_LANG_MAP.get(target_locale)

    if not target_lang:

        raise ValueError(f"不支持的语言:{target_locale}")

  


    translator = GoogleTranslator(source=source_lang, target=target_lang)

    return translator.translate(text)

  


  


# ── 翻译:OpenAI(可选备用)──────────────────────────────────────────────────────

def translate_openai(text: str, target_locale: str, source_locale: str = "zh-Hans") -> str:

    api_key = os.environ.get("OPENAI_API_KEY", "")

    if not api_key:

        raise EnvironmentError("OPENAI_API_KEY 未设置")

  


    target_lang = OPENAI_LANG_MAP.get(target_locale)

    if not target_lang:

        raise ValueError(f"不支持的语言:{target_locale}")

  


    resp = httpx.post(

        "https://api.openai.com/v1/chat/completions",

        headers={

            "Authorization": f"Bearer {api_key}",

            "Content-Type": "application/json",

        },

        json={

            "model": "gpt-4o-mini",

            "messages": [

                {

                    "role": "system",

                    "content": (

                        f"You are a professional app store localization translator. "

                        f"Translate the following App Store release notes to {target_lang}. "

                        f"Keep the bullet point format (•). "

                        f"Keep it concise and natural for app store audiences. "

                        f"Output only the translated text, nothing else."

                    ),

                },

                {"role": "user", "content": text},

            ],

            "temperature": 0.3,

        },

        timeout=30,

    )

    resp.raise_for_status()

    return resp.json()["choices"][0]["message"]["content"].strip()

  


  


def translate(text: str, target_locale: str, source_locale: str = "zh-Hans") -> str:

    """优先使用免费 Google 翻译,失败时回退到 OpenAI"""

    try:

        return translate_google(text, target_locale, source_locale)

    except Exception as e:

        warn(f"Google 翻译失败,尝试 OpenAI:{e}")

        return translate_openai(text, target_locale, source_locale)

  


  


# ── App Store Connect API ──────────────────────────────────────────────────────

ASC_BASE = "https://api.appstoreconnect.apple.com/v1"

  


  


def get_latest_version(app_id: str, token: str) -> dict:

    """获取最新的版本(不过滤状态,客户端筛选)"""

    headers = {"Authorization": f"Bearer {token}"}

  


    resp = httpx.get(

        f"{ASC_BASE}/apps/{app_id}/appStoreVersions",

        headers=headers,

        params={

            "limit": 10,

        },

        timeout=30,

    )

    resp.raise_for_status()

    data = resp.json()["data"]

  


    # API 不认 sort,客户端自己排序(按版本号降序)

    data.sort(

        key=lambda v: tuple(int(x) for x in v["attributes"]["versionString"].split(".")),

        reverse=True,

    )

  


    if not data:

        raise RuntimeError(

            "未找到任何版本。请先在 App Store Connect 创建新版本。"

        )

  


    # 找到第一个可编辑的版本(pre-release 状态)

    for version in data:

        state = version["attributes"]["appStoreState"]

        if state in (

            "PREPARE_FOR_SUBMISSION",

            "DEVELOPER_REJECTED",

            "REJECTED",

            "METADATA_REJECTED",

            "WAITING_FOR_REVIEW",

            "READY_FOR_REVIEW",

        ):

            return version

  


    # 如果没有可编辑版本,返回最新版本(允许尝试)

    latest = data[0]

    latest_state = latest["attributes"]["appStoreState"]

    raise RuntimeError(

        f"最新版本状态为 {latest_state},不可编辑。"

        "请先在 App Store Connect 创建新版本(状态应为 Prepare for Submission)。"

    )

  


  


def get_version_localizations(version_id: str, token: str) -> list:

    """获取版本的所有语言本地化记录"""

    resp = httpx.get(

        f"{ASC_BASE}/appStoreVersions/{version_id}/appStoreVersionLocalizations",

        headers={"Authorization": f"Bearer {token}"},

        timeout=30,

    )

    resp.raise_for_status()

    return resp.json()["data"]

  


  


def create_localization(version_id: str, locale: str, notes: str, token: str) -> dict:

    """新建一个语言本地化记录"""

    resp = httpx.post(

        f"{ASC_BASE}/appStoreVersionLocalizations",

        headers={

            "Authorization": f"Bearer {token}",

            "Content-Type": "application/json",

        },

        json={

            "data": {

                "type": "appStoreVersionLocalizations",

                "attributes": {

                    "locale": locale,

                    "whatsNew": notes,

                },

                "relationships": {

                    "appStoreVersion": {

                        "data": {"type": "appStoreVersions", "id": version_id}

                    }

                },

            }

        },

        timeout=30,

    )

    resp.raise_for_status()

    return resp.json()["data"]

  


  


def update_localization(loc_id: str, notes: str, token: str) -> dict:

    """更新已有的语言本地化记录"""

    resp = httpx.patch(

        f"{ASC_BASE}/appStoreVersionLocalizations/{loc_id}",

        headers={

            "Authorization": f"Bearer {token}",

            "Content-Type": "application/json",

        },

        json={

            "data": {

                "type": "appStoreVersionLocalizations",

                "id": loc_id,

                "attributes": {"whatsNew": notes},

            }

        },

        timeout=30,

    )

    resp.raise_for_status()

    return resp.json()["data"]

  


  


# ── 主流程 ─────────────────────────────────────────────────────────────────────

def main():

    print(f"\n{C.BOLD}{'=' * 55}{C.RESET}")

    print(f"{C.BOLD}  App Store Connect 多语言发版说明上传工具{C.RESET}")

    print(f"{C.BOLD}{'=' * 55}{C.RESET}")

  


    # 1. 读取配置

    section("① 读取配置")

    notes_file = SCRIPT_DIR / "release_notes.yaml"

    if not notes_file.exists():

        err(f"找不到配置文件:{notes_file}")

        sys.exit(1)

  


    with open(notes_file, encoding="utf-8") as f:

        config = yaml.safe_load(f)

  


    version_str   = config.get("version", "Unknown")

    source_locale = config.get("source_language", "zh-Hans")

    notes_map     = config.get("notes", {})

  


    source_text = notes_map.get(source_locale, "").strip()

    if not source_text:

        err(f"源语言 ({source_locale}) 内容为空,请先填写更新说明")

        sys.exit(1)

  


    ok(f"版本:{version_str}")

    ok(f"源语言:{source_locale}")

    ok(f"目标语言数:{len(notes_map)}")

  


    # 2. 自动翻译空白语言

    section("② 自动翻译(Google 翻译)")

    if not os.environ.get("OPENAI_API_KEY"):

        info("未配置 OPENAI_API_KEY,仅使用 Google 翻译(免费)")

  


    translated_notes = {}

  


    for locale, text in notes_map.items():

        if locale == source_locale:

            translated_notes[locale] = source_text

            ok(f"{locale}  (原文,跳过翻译)")

            continue

  


        if text.strip():

            translated_notes[locale] = text.strip()

            ok(f"{locale}  (已手动填写,跳过翻译)")

            continue

  


        info(f"{locale}  翻译中...")

        try:

            translated = translate(source_text, locale, source_locale)

            translated_notes[locale] = translated

            ok(f"{locale}  翻译完成")

        except Exception as e:

            warn(f"{locale}  翻译失败:{e},跳过此语言")

  


    # 3. 生成 ASC Token

    section("③ 连接 App Store Connect")

    try:

        token = generate_asc_token()

        ok("JWT Token 生成成功")

    except KeyError as e:

        err(f"缺少环境变量:{e},请检查 .env 文件")

        sys.exit(1)

    except Exception as e:

        err(f"Token 生成失败:{e}")

        sys.exit(1)

  


    # 4. 查找版本

    app_id = os.environ.get("ASC_APP_ID", "")

    if not app_id:

        err("缺少环境变量:ASC_APP_ID")

        sys.exit(1)

  


    try:

        version_obj = get_latest_version(app_id, token)

        version_id  = version_obj["id"]

        version_num = version_obj["attributes"]["versionString"]

        state       = version_obj["attributes"]["appStoreState"]

        ok(f"找到版本:{version_num}  状态:{state}")

    except Exception as e:

        err(f"获取版本失败:{e}")

        sys.exit(1)

  


    # 5. 获取已有本地化记录

    try:

        existing_locs = get_version_localizations(version_id, token)

        existing_map  = {loc["attributes"]["locale"]: loc["id"] for loc in existing_locs}

        ok(f"已有本地化记录:{len(existing_map)} 个")

    except Exception as e:

        err(f"获取本地化记录失败:{e}")

        sys.exit(1)

  


    # 6. 批量上传

    section("④ 上传更新说明")

    success_count = 0

    fail_count    = 0

  


    for locale, notes in translated_notes.items():

        asc_locale = LOCALE_MAP.get(locale, locale)

        try:

            if asc_locale in existing_map:

                update_localization(existing_map[asc_locale], notes, token)

                ok(f"{asc_locale}  已更新")

            else:

                create_localization(version_id, asc_locale, notes, token)

                ok(f"{asc_locale}  已创建")

            success_count += 1

        except Exception as e:

            warn(f"{asc_locale}  上传失败:{e}")

            fail_count += 1

  


    # 7. 输出翻译结果预览(供人工复核)

    section("⑤ 翻译结果预览")

    preview_file = SCRIPT_DIR / f"release_notes_preview_{version_str.replace('.', '_')}.txt"

    try:

        with open(preview_file, "w", encoding="utf-8") as f:

            f.write(f"版本 {version_str} 发版说明预览\n")

            f.write(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")

            f.write("=" * 55 + "\n\n")

            for locale, notes in translated_notes.items():

                f.write(f"[{locale}]\n{notes}\n\n")

        ok(f"预览文件已保存:scripts/{preview_file.name}")

    except (OSError, PermissionError):

        warn(f"无法写入预览文件,跳过")

        # 直接在终端打印翻译结果

        print()

        for locale, notes in translated_notes.items():

            print(f"  [{locale}]\n{notes}\n")

  


    # 8. 最终汇报

    print(f"\n{C.BOLD}{'=' * 55}{C.RESET}")

    if fail_count == 0:

        print(f"{C.GREEN}{C.BOLD}  全部完成!成功上传 {success_count} 个语言{C.RESET}")

    else:

        print(f"{C.YELLOW}{C.BOLD}  完成(成功 {success_count},失败 {fail_count}{C.RESET}")

    print(f"{C.BOLD}{'=' * 55}{C.RESET}\n")

  


  


if __name__ == "__main__":

    main()

scripts/requirements.txt


PyJWT>=2.8.0

httpx>=0.27.0

pyyaml>=6.0.1

python-dotenv>=1.0.0

cryptography>=41.0.0

deep-translator>=1.11.4

scripts/.env.example


# =====================================================

# App Store Connect 多语言上传工具 - 环境变量配置

# =====================================================

# 将此文件复制为 scripts/.env,填写真实值后保存

# .env 文件已在 .gitignore 中,不会上传到 git

# =====================================================

  


# ── App Store Connect API(必填)─────────────────────

# 来源:App Store Connect → 用户与访问 → 密钥 → App Store Connect API

  


# Key ID(示例:ABCD1234EF)

ASC_KEY_ID=your_key_id_here

  


# Issuer ID(示例:57246542-96fe-1a63-e053-0824d011072a)

ASC_ISSUER_ID=your_issuer_id_here

  


# .p8 私钥文件路径(下载后放到 scripts/ 目录)

ASC_PRIVATE_KEY=scripts/AuthKey_XXXXXXXXXX.p8

  


# 应用的 Apple ID(在 App Store Connect → 应用信息 → Apple ID 下方找到,纯数字)

ASC_APP_ID=1234567890

  


# ── 翻译引擎 ─────────────────────────────────────────

# 默认使用 Google 翻译(免费,无需 API Key,无需信用卡)

# 如果 Google 翻译失败,脚本会自动回退到 OpenAI

  


# OpenAI API Key(可选备用,不填也能用)

# 注册:https://platform.openai.com/api-keys

# OPENAI_API_KEY=your_openai_api_key_here

scripts/release_notes.yaml 模板


# App Store 发版说明配置

# 使用方法:

#   1. 修改 version 和源语言下的内容

#   2. 其他语言留空(""),脚本会自动翻译

#   3. 也可以手动填写某个语言,填写后脚本跳过该语言的翻译

#   4. 运行:python3 scripts/upload_notes.py

  


version: "2.1.0"

  


# 源语言(翻译的来源)

source_language: "zh-Hans"

  


# 各语言更新说明

# 留空 ("") 表示由脚本自动翻译填充

# 手动填写则跳过翻译直接使用

notes:

  zh-Hans: |

     新增某某功能

     优化性能与体验

     修复若干已知问题

  en-US: ""

  zh-Hant: ""

  de-DE: ""

  fr-FR: ""

  es-ES: ""

  pt-BR: ""

  it: ""

  tr: ""


常见问题与排查

「未找到可编辑的版本」或「最新版本状态为 READY_FOR_SALE,不可编辑」

原因:当前 App 没有处于草稿/待提交状态的版本。

解决:在 App Store Connect 网页端手动创建新版本,状态变为 Prepare for Submission 后再运行脚本。

「找不到 .p8 私钥文件」

原因ASC_PRIVATE_KEY 路径不正确,或文件未放到指定位置。

解决:确认 .p8 文件存在,路径相对于项目根目录(如 scripts/AuthKey_ABC123.p8)。

「缺少环境变量」

原因.env 未创建或变量名拼写错误。

解决:确认 scripts/.env 存在且四个 ASC 变量均已填写。

Google 翻译失败

原因:网络问题或 Google 服务暂时不可用。

解决

  1. 检查网络(国内可能需要代理)

  2. 配置 OPENAI_API_KEY 作为备用

  3. 或在 YAML 中手动填写翻译内容

某语言上传失败

原因:该 locale 未在 App Store Connect 的 App 信息中启用。

解决:在 ASC → App 信息 → 本地化语言中添加对应语言,或从 release_notes.yaml 中移除该语言。

pip: command not found / python: command not found

macOS 请使用:


brew install python3

pip3 install -r scripts/requirements.txt

python3 scripts/upload_notes.py

JWT 401 Unauthorized

原因:Key ID、Issuer ID 与 .p8 文件不匹配,或密钥权限不足。

解决:核对三项配置是否来自同一把密钥,权限需包含 App 管理。


安全建议

  1. 永远不要.p8 私钥、scripts/.env 提交到 Git

  2. 建议在 .gitignore 中加入:

   ```gitignore

   scripts/.env

   scripts/*.p8

   scripts/release_notes_preview_*.txt

   ```

  1. API 密钥权限遵循最小原则,App 管理即可,无需 Admin

  2. 若密钥泄露,立即在 App Store Connect 中吊销并重新生成

  3. OpenAI API Key 同样不要硬编码在脚本中


扩展与定制

增加新语言

  1. release_notes.yamlnotes 下添加新 locale

  2. upload_notes.pyLOCALE_MAPGOOGLE_LANG_MAPOPENAI_LANG_MAP 中补充映射

  3. 在 App Store Connect 中启用该语言

接入 CI/CD

可在 Fastlane 或 GitHub Actions 中于发版前自动执行:


# .github/workflows/upload-release-notes.yml 示例

- name: Upload App Store release notes

  env:

    ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}

    ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}

    ASC_PRIVATE_KEY: scripts/AuthKey.p8

    ASC_APP_ID: ${{ secrets.ASC_APP_ID }}

  run: |

    pip3 install -r scripts/requirements.txt

    python3 scripts/upload_notes.py

.p8 内容存入 GitHub Secrets,在 workflow 中写入临时文件即可。

替换翻译引擎

修改 translate() 函数,可接入 DeepL、Azure Translator、自建 LLM 等,保持输入输出接口不变即可。

仅预览不上传

main() 中第 ⑥ 步「批量上传」注释掉,或增加 --dry-run 参数(自行扩展),即可只生成预览文件、不调用 API。


总结

这套工具把 iOS 发版中最繁琐的「多语言 What's New 填写」变成了配置驱动的一次性操作

  • 开发侧:只维护一份源语言文案

  • 翻译侧:Google 免费翻译 + OpenAI 可选降级

  • 发布侧:App Store Connect API 自动同步

整个脚本约 430 行 Python,无框架依赖,易于复制到其他 iOS 项目。