App Store Connect の管理画面を開いて、スペイン語ロケールのキーワード欄が英語のままなのを見つけたのが、この作業の出発点でした。
100文字のキーワード欄を、40本分も手で書き直せなかった
App Store Connect のキーワード欄は、1ロケールあたり100文字しかありません。たった100文字ですが、ここに何を入れるかでオーガニック流入ははっきり変わります。問題は、私が運営している壁紙系アプリが40本ほどあり、それぞれに日本語・英語・スペイン語・ドイツ語・ポルトガル語・繁体字中国語のキーワードを用意しようとすると、40本 × 6ロケール = 240枠を手作業で埋めることになる、という点でした。
これまでは正直、日本語と英語の2ロケールだけ埋めて、残りは空欄か英語の使い回しで放置していました。海外ユーザーが収益の半分以上を占めているのに、です。1枠あたり15分と見積もっても60時間。まとまった時間が取れないという理由で、何年も先送りにしてきた作業でした。
そこで2026年4月末、Gemini 2.5 Flash にキーワードの下書きを任せる仕組みを組みました。本記事は、その1ヶ月の運用ログと所感の整理です。完成された最適解ではなく、つまずいた点をそのまま残します。
なぜ Pro ではなく Flash を選んだか — 240枠のトークンを実際に数える
最初に Gemini 2.5 Pro で試したのですが、キーワード生成というタスクには明らかにオーバースペックでした。1枠あたりの入力はアプリ名・カテゴリ・特徴を箇条書きにした200トークン程度で、出力も100文字以内。推論の深さより、240枠を安く速く回せることのほうが重要です。
実測で、Flash は1枠あたりの応答が1秒前後、Pro は3〜5秒でした。240枠を一晩のバッチで回すなら、この差は無視できません。
コスト感も、規模を数えてしまえば判断は簡単でした。1枠あたり入力200トークン・出力100トークン程度なので、240枠を1周すると入力48,000トークン・出力24,000トークン。プロンプトを調整しながら3周しても入力144,000トークン・出力72,000トークンにとどまります。Flash クラスの単価であれば、この規模は数十円に収まる規模です。ここで Pro を選ぶ理由が、私には見つかりませんでした。
| 観点 | Gemini 2.5 Pro | Gemini 2.5 Flash |
|---|---|---|
| 1枠あたりの応答(実測) | 3〜5秒 | 1秒前後 |
| 240枠のバッチ所要(概算) | 15〜20分 | 4〜5分 |
| プロンプト調整3周分のトークン | 入力 約144,000 / 出力 約72,000 | |
| 品質面の判断 | 過剰 | 構造化出力+検証で十分 |
タスクの難易度に対してモデルを過剰に盛らない。これは現場では一つの規律にしています。単価そのものより、「何トークン流れるのか」を先に数えるほうが、判断が速く済みました。
キーワードは「自由作文」ではなく構造化出力で受け取る
最初の失敗は、プロンプトに「カンマ区切りで100文字以内のキーワードを出して」と書いて自由形式で受け取ったことでした。Flash は素直にキーワードを返してくれるのですが、たまに説明文を添えたり、句点を入れたり、100文字を数文字超えたりします。240枠もあると、こうした「ほぼ正しいけれど機械処理に乗らない」出力の後始末が地獄になります。
そこで responseSchema で型を固定し、キーワードを配列で受け取る形に変えました。文字数の制御はモデルに任せず、受け取った後にこちら側で詰めます。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def draft_keywords(app_name: str, features: list[str], locale: str) -> list[str]:
prompt = (
f"App Store のキーワード欄に入れる検索語を提案してください。\n"
f"アプリ名: {app_name}\n"
f"特徴: {', '.join(features)}\n"
f"ロケール: {locale}\n"
f"・各語は {locale} のユーザーが実際に検索する自然な語にする\n"
f"・アプリ名やカテゴリ名に既に含まれる語は含めない\n"
f"・1語あたり2〜12文字程度、20語以内"
)
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "array",
"items": {"type": "string"},
},
temperature=0.4,
),
)
return [w.strip() for w in res.parsed if w.strip()]配列で受け取れば、後段で「重複排除 → 文字数チェック → 100文字に詰める」を確定的なコードで処理できます。生成の不確実性をモデルに残さず、編集可能な素材として受け取るのがコツでした。
temperature を 0.4 にしているのは、0 に寄せると語彙が硬直して同じ語ばかり出てくるためです。逆に 0.8 まで上げるとロケールの実際の検索語から離れた造語が混ざり始めました。20語出させて後段で削る前提なら、私自身はこの中間あたりがいちばん扱いやすいと感じています。
文字数の確定はモデルではなく、こちらのコードで
App Store のキーワード欄は「100文字」と表記されますが、日本語や繁体字中国語では実質的に文字数で数えられる一方、Gemini に文字数制御を任せると len() ベースの数え方とロケールごとの全角・半角の扱いがずれて、ビルド時の検証に何度も引っかかりました。
最終的に、文字数の確定はモデルではなく自分のコードで行う方針に統一しました。Gemini には「候補を多めに出す」ことだけを期待し、100文字への詰め込みは優先度順に確定的に行います。
def pack_keywords(words: list[str], limit: int = 100) -> str:
seen, packed, length = set(), [], 0
for w in words:
key = w.lower()
if key in seen:
continue
add = len(w) + (1 if packed else 0) # カンマ1文字を加算
if length + add > limit:
continue
packed.append(w)
seen.add(key)
length += add
return ",".join(packed)continue にしている点が地味に効きます。break にすると、長い語が1つ来た時点で以降の短い語が全て捨てられてしまいます。優先度の高い語から詰めつつ、入らなかった語は飛ばして次を試す。これだけで、残り3〜5文字の隙間に短い語が1つ余分に入るようになりました。
公式ヘルプには「キーワードはカンマ区切り、スペース不要」と書かれていますが、ここで素直にスペースを入れてしまうと貴重な100文字を浪費します。Gemini の出力にもスペースが混ざることがあるので、pack の段階で除去しています。細かい話ですが、100文字しかない世界では1文字が効きます。
タイトル・サブタイトルとの重複排除が一番効いた
ASO で最も効果が出たのは、実は凝ったキーワード生成ではなく、地味な重複排除でした。App Store はアプリ名・サブタイトル・キーワード欄を合わせて索引します。つまりタイトルに「壁紙」と入っているなら、キーワード欄にもう一度「壁紙」と入れるのは100文字の無駄遣いです。
そこで、各アプリのタイトルとサブタイトルを形態素分割して除外語リストを作り、Gemini に「この語は使わないでほしい」と渡したうえで、生成後にもう一度こちら側でフィルタしました。二重に守ったのは、モデルへの指示だけだと2割ほどは除外語が混ざって戻ってきたからです。指示と検証は両輪で考えるのが安全だと、改めて感じました。
この重複排除だけで、空いた文字数に検索ボリュームのある別語を入れられるようになり、1本あたり平均で3〜4語ぶんの「枠」を取り戻せました。
禁止語・重複・長さを、1本の検証ゲートにまとめた
もう一つの落とし穴が、競合のアプリ名や商標を入れてしまうことです。Gemini は素直なので、特徴に「人気の◯◯風」のような文脈があると、他社ブランド名を検索語として提案してくることがありました。これは審査リジェクトや法的リスクにつながります。
当初はチェックを何箇所かに散らしていたのですが、どこで落ちたのか追えなくなったため、理由つきで弾く関数に集約しました。
BLOCKED = {"instagram", "pinterest", "photoshop"} # 他社ブランド・商標
def validate(words: list[str], excluded: set[str], locale: str) -> tuple[list[str], list[str]]:
kept, rejected = [], []
for w in words:
key = w.lower().strip()
if not key:
rejected.append(f"{w}: 空文字")
elif key in BLOCKED:
rejected.append(f"{w}: ブロックリスト該当")
elif key in excluded:
rejected.append(f"{w}: タイトル/サブタイトルと重複")
elif " " in w:
rejected.append(f"{w}: スペースを含む")
elif len(w) > 20:
rejected.append(f"{w}: 長すぎる({len(w)}文字)")
else:
kept.append(w)
return kept, rejected戻り値に rejected を含めたのが、運用上いちばん助かった判断でした。240枠を回すと除外は毎回100件近く出ますが、理由つきで残るので「ブロックリスト該当が急に増えた」といった異常にすぐ気づけます。除外を黙って捨てていた頃は、プロンプトを変えた影響が見えませんでした。
ここはモデルの賢さに任せてはいけない領域です。法的・規約的な判断が必要な箇所は、必ず確定的なコードで最後の砦を作る。AI に下書きさせること自体は効率的でも、最終責任は運営者にあるからです。
ロケールごとに、効きどころがはっきり違った
240枠を一度に回して分かったのは、同じプロンプトでもロケールによって結果の性格が変わることでした。ここは公式ドキュメントには書かれていない、実際に流してみないと見えない部分です。
| ロケール | 起きたこと | とった対処 |
|---|---|---|
| ドイツ語 | 複合語が長く、1語で15文字以上を占めることがある | 語長の上限を設け、長い複合語は分割候補も併記させる |
| 繁体字中国語 | 1文字あたりの情報量が高く、100文字にかなり詰め込める | 候補を20語から30語へ増やして余白を埋める |
| スペイン語・ポルトガル語 | アクセント記号の有無で表記ゆれが出る | 両方を候補に残し、重複排除では別語として扱う |
| 日本語 | ひらがな・カタカナ・漢字の表記ゆれが出る | 実際の検索語に寄せるため、カタカナ表記を優先 |
ドイツ語の複合語は特に厄介で、100文字のうち3語しか入らない枠が出ました。分割した短い語を併記する形に変えたところ、同じ枠に6〜7語が入るようになっています。ロケールごとにプロンプトを分ける手もありましたが、管理する分岐が増えるのを避け、語長の上限だけをロケール別のパラメータにする形で落ち着きました。
1ヶ月運用して残った所感と、うまくいかなかったこと
正直なところ、キーワードを変えてから1ヶ月で売上が劇的に伸びた、という派手な話はありません。壁紙という競合の多いカテゴリでは、1ヶ月でのインプレッション変動は季節要因やフィーチャーの影響と切り分けにくいのが実情です。それでも、これまで英語使い回しだったロケールに母国語キーワードを入れたアプリでは、検索経由の表示回数が緩やかに増える傾向が見えました。
うまくいかなかったこともあります。当初は「検索ボリュームの高い語を優先して並べてほしい」とプロンプトで指示していたのですが、モデルは検索ボリュームの実データを持っていないため、それらしい順序が返るだけでした。数字の裏付けがない優先順位は、無いほうがましだと判断して指示を外しています。モデルに求めてよいのは候補の網羅であって、順位付けではなかった、という整理です。
何より大きかったのは、240枠という「人力では永遠に手をつけない作業」が、現実的な工数で回せるようになったことです。Gemini に下書きさせ、自分のコードで詰めて検証し、最後に目で確認する。この分業が定着したことで、ロケール追加の心理的なハードルがほぼ消えました。
技術そのものより、「やらないまま放置していた仕事を、やれる仕事に変えてくれた」ところに価値を感じています。次は、ロケールごとの実際の検索ボリュームデータを取り込んで、語の優先順位付けまで自動化したいと考えています。同じように多ロケール展開で手が止まっている個人開発の方は、まず1本のアプリの1ロケールだけで validate の rejected を眺めるところから始めてみてください。何が弾かれているかが見えると、次に何を直すべきかが決まります。