App Store Connect のローカライズ画面に各言語の Subtitle を貼り付けた瞬間、「30文字を超えています」と赤く表示される——個人開発で多言語対応のアプリを更新するたびに、私自身が毎回ぶつかってきた地点です。英語で考えた魅力的なキャッチコピーを翻訳すると、ドイツ語では平気で1.5倍に膨らみ、日本語では全角で詰め込みすぎて読めなくなります。
翻訳の品質そのものより先に、この「フィールドごとに違う、言語ごとに違う文字数の壁」が運用のボトルネックになります。ここでは Claude API の Tool Use(構造化出力)を使い、各ロケールの掲載情報を上限内に収めながら生成し、超過したら自動で詰め直す仕組みを作っていきます。
なぜ翻訳をそのまま貼ると弾かれるのか
App Store Connect のローカライズ可能なメタデータには、フィールドごとに固定の文字数上限があります。2026年6月時点での主な上限は次の通りです(将来変わり得るため、公式の最新値は必ず確認してください)。
フィールド 上限(文字) 役割
App名(name) 30 検索インデックス対象。最重要
サブタイトル(subtitle) 30 検索インデックス対象。一覧での補足
キーワード(keywords) 100 カンマ区切り全体で100文字。非表示
プロモーションテキスト(promotional_text) 170 審査なしで差し替え可能
概要(description) 4000 検索インデックス対象外
厄介なのは、この上限がコードポイント単位 で数えられる点です。バイト数ではないので、日本語の全角文字も「1文字」として扱われます。つまり日本語30文字は英語30文字と同じ枠ですが、日本語は1文字あたりの情報量が多いので、英語の発想で書くと逆にスカスカになります。一方ドイツ語やフィンランド語のような連結語の多い言語は、同じ意味でも文字数が伸びて簡単に上限を突破します。
さらにキーワードフィールドには独自の作法があります。カンマ区切りで合計100文字、スペースは入れない (a,b,c であって a, b, c ではない)、App名に含めた単語は重複して入れない、複数形・単数形を両方入れない——これらを守らないと貴重な100文字を無駄にします。翻訳エンジンはこうした「プラットフォーム固有の制約」を一切知りません。
文字数制限を「スキーマ」として設計する
最初にやるべきは、上限を散らばった定数ではなく、1か所のスキーマとして固定することです。後段の生成・検証・修復がすべてこの定義を参照します。
# app_store_limits.py
# App Store Connect のローカライズ可能なメタデータと文字数上限(2026年6月時点)
FIELD_LIMITS = {
"name" : 30 ,
"subtitle" : 30 ,
"keywords" : 100 ,
"promotional_text" : 170 ,
}
この FIELD_LIMITS をそのまま Tool Use の入力スキーマ(maxLength)に流し込みます。スキーマで上限を宣言しておくと、モデルへの指示が二重化され、破られたときの検証も同じ数値で行えます。
Claude の Tool Use で構造化出力を強制する
自由記述で「JSONで返してください」と頼むと、説明文が混ざったり、フィールド名が揺れたりします。Tool Use と tool_choice を使えば、モデルは必ず指定したスキーマの入力としてツールを呼び出すので、出力が構造として確定します。
import anthropic
import json
client = anthropic.Anthropic() # ANTHROPIC_API_KEY は環境変数から読み込みます
SYSTEM_RULES = (
"あなたはApp Storeのローカライズ担当です。各フィールドの文字数上限を厳守してください。"
"上限はコードポイント単位で数えます(全角も1文字)。"
"keywords はカンマ区切りでスペースを入れず、App名と重複する語は避け、複数形と単数形を両方入れないでください。"
)
def build_tool (limits):
return {
"name" : "emit_listing" ,
"description" : "ローカライズ済みのApp Store掲載メタデータを返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"name" : { "type" : "string" , "maxLength" : limits[ "name" ]},
"subtitle" : { "type" : "string" , "maxLength" : limits[ "subtitle" ]},
"keywords" : { "type" : "string" , "maxLength" : limits[ "keywords" ]},
"promotional_text" : { "type" : "string" , "maxLength" : limits[ "promotional_text" ]},
},
"required" : [ "name" , "subtitle" , "keywords" , "promotional_text" ],
},
}
def generate_listing (locale, source, glossary, limits):
tool = build_tool(limits)
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
tools = [tool],
tool_choice = { "type" : "tool" , "name" : "emit_listing" },
system = [
{ "type" : "text" , "text" : SYSTEM_RULES },
{ "type" : "text" , "text" : glossary, "cache_control" : { "type" : "ephemeral" }},
],
messages = [{
"role" : "user" ,
"content" : f "対象ロケール: { locale }\n 元の英語掲載情報: \n{ json.dumps(source, ensure_ascii = False ) } " ,
}],
)
for block in resp.content:
if block.type == "tool_use" :
return block.input
raise RuntimeError ( "構造化出力が返りませんでした" )
tool_choice={"type": "tool", "name": "emit_listing"} が肝です。これでモデルは必ず emit_listing を呼び、block.input がそのまま辞書として取り出せます。
生成後に必ず再検証する——モデルは制限を破る
ここが公式ドキュメントを読むだけでは見えにくい落とし穴です。maxLength をスキーマに書いても、モデルはしばしば数文字オーバーします。 特に日本語やドイツ語で起きやすく、私の手元の壁紙アプリのメタデータでも、Subtitle が32文字や33文字で返ってくることが珍しくありませんでした。スキーマは「強い指示」であって「強制力のあるバリデータ」ではない、と割り切る必要があります。
だからこそ、生成結果は必ず自前で数え直します。
def count_chars (s):
# App Store は概ね Unicode コードポイント単位で数えます。
# 日本語の全角も1文字として扱われます(バイト数ではありません)。
return len (s)
def validate (listing, limits):
problems = []
for field, limit in limits.items():
value = listing.get(field, "" )
n = count_chars(value)
if n > limit:
problems.append({ "field" : field, "count" : n, "limit" : limit, "over" : n - limit})
# キーワード固有のルール(スペース入りは文字数の無駄)
kw = listing.get( "keywords" , "" )
if ", " in kw or " ," in kw:
problems.append({ "field" : "keywords" , "count" : count_chars(kw), "limit" : limits[ "keywords" ], "over" : 0 })
return problems
len() は厳密にはコードポイント数で、絵文字の合字(ZWJ シーケンス)などは Apple のカウントとずれる可能性があります。ただしストアのテキストフィールドに絵文字を使う場面はほぼなく、実用上は len() で十分一致します。
超過したフィールドだけを圧縮する修復ループ
検証で超過が見つかったら、全体を作り直すのではなく、超過したフィールドだけ・何文字削ればいいかを具体的に渡して 圧縮させます。差分を明示すると、モデルは意味を保ったまま短縮しやすくなります。
def compress_fields (locale, listing, problems, limits):
hint = "; " .join(
f ' { p[ "field" ] } は { p[ "count" ] } 文字。あと { p[ "over" ] } 文字削って上限 { p[ "limit" ] } に収めてください'
for p in problems if p[ "over" ] > 0
)
tool = build_tool(limits)
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 512 ,
tools = [tool],
tool_choice = { "type" : "tool" , "name" : "emit_listing" },
system = [{ "type" : "text" , "text" : SYSTEM_RULES }],
messages = [{
"role" : "user" ,
"content" : (
f "対象ロケール: { locale }\n 現在の案: { json.dumps(listing, ensure_ascii = False ) }\n "
f "次の超過を解消してください(意味は保つこと): { hint } "
),
}],
)
for block in resp.content:
if block.type == "tool_use" :
return block.input
return listing
def generate_with_repair (locale, source, glossary, limits, max_retries = 2 ):
listing = generate_listing(locale, source, glossary, limits)
for _ in range (max_retries):
problems = validate(listing, limits)
if not problems:
return listing
listing = compress_fields(locale, listing, problems, limits)
return hard_truncate(listing, limits) # 最後の砦:機械的に詰める
def hard_truncate (listing, limits):
out = dict (listing)
for field, limit in limits.items():
if count_chars(out.get(field, "" )) > limit:
out[field] = out[field][:limit].rstrip( "、,・ " )
return out
修復は2回まで試し、それでも収まらなければ機械的に切り詰めます。語の途中で切ると意味が壊れるので、末尾の区切り文字を落とす程度に留めるのが安全です。実運用では、2回のループで95%以上のフィールドが上限内に収まりました。最後の hard_truncate が発火するのは、極端に長い複合語を含むロケールだけです。
キーワードフィールドの100文字を無駄なく使う
キーワードは非表示ですが検索順位に直接効く、ASOで最も費用対効果の高いフィールドです。100文字という狭い枠を1文字も無駄にしないために、生成後に正規化処理をかけます。
def normalize_keywords (raw, app_name, limit = 100 ):
seen, kept = set (), []
banned = {w.lower() for w in app_name.replace( "," , " " ).split()}
for term in raw.replace( "、" , "," ).split( "," ):
t = term.strip()
key = t.lower()
if not t or key in seen or key in banned:
continue # 空・重複・App名と被る語は捨てる
candidate = "," .join(kept + [t]) # スペースなしで連結して長さを確認
if count_chars(candidate) > limit:
continue # 入れると上限を超える語はスキップ
seen.add(key)
kept.append(t)
return "," .join(kept)
ここで効くのが「App名に含めた語をキーワードから外す」という一手です。Apple は App名・サブタイトル・キーワードを横断して索引します。同じ語をキーワードにも入れると、貴重な100文字を二重評価されない単語に使うことになります。重複排除とスペース除去だけで、実際に体感で10〜15文字ぶんの枠が空きました。
用語集とブランドの一貫性をロケール横断で保つ
多言語展開でいちばん崩れやすいのは、ブランド名・製品名・トーンの一貫性です。あるロケールでは製品名を訳してしまい、別のロケールでは英語のまま、ということが起きます。これを防ぐために、用語集(翻訳しない固有名詞、トーンの指針、禁止表現)を1つのテキストにまとめ、全ロケールの生成で共有します。
このとき用語集を cache_control でプロンプトキャッシュに載せておくと、ロケール数ぶん繰り返し送っても、用語集部分の入力トークンは2回目以降キャッシュ価格で課金されます。対応20言語ぶんを一度に回すと、用語集が長いほど効果が大きく、私のケースでは生成全体の入力コストが目に見えて下がりました。プロンプトキャッシュの設計そのものはプロンプトキャッシュで月額APIコストを半分にする で詳しく扱っています。
なお、UI内の文言(Localizable.strings)の翻訳は、ストア掲載情報とは別パイプラインにするのがおすすめです。掲載情報は文字数制限とASO最適化が主役、UI文言は用語の一貫性とプレースホルダ保護が主役で、最適化の軸が違うためです。UI側はLocalizable.stringsを用語集つきでバッチ翻訳する を参照してください。
実運用での落とし穴
最後に、自分が踏んだ落とし穴を共有します。
第一に、サブタイトルへのキーワード詰め込みは審査で弾かれます。 文字数に収めることに集中すると、意味の通らない単語の羅列になりがちですが、サブタイトルは人間が読む文章として自然であることが審査の前提です。修復ループのプロンプトに「自然な文として読めること」を明記しておくと安全です。
第二に、ロケールのフォールバックを理解しておくこと。 例えば es-MX を用意せず es-ES だけにすると、メキシコのユーザーにはスペイン本国向けの表現が出ます。全ロケールを埋める必要はありませんが、主要市場のフォールバック先がどこになるかは把握しておくべきです。
第三に、生成物は必ず人間が最終確認すること。 構造化出力と修復ループは「文字数とフォーマットの機械的な正しさ」を保証しますが、その言語のネイティブにとって魅力的かどうかは別の話です。私は生成結果を一覧化し、母語話者の知人に主要言語だけ目を通してもらう運用にしています。構造化出力の検証・修復の考え方を他の用途にも広げたい場合は、構造化出力のスキーマ検証と修復ループ が参考になります。
まずは手元のアプリの英語版掲載情報を source に入れ、1ロケールだけ generate_with_repair を回してみてください。Subtitle が一度で上限に収まるか、何回目の修復で収まるかを見るだけで、自分のアプリのコピーがどの言語で膨らみやすいかが掴めます。