2014年からの個人開発と、累計5,000万ダウンロードに育った壁紙系アプリの運営経験を踏まえて、現場で効いたところだけを抽出します。
100文字のキーワード欄を、40本分も手で書き直せなかった
App Store Connect のキーワード欄は、1ロケールあたり100文字しかありません。たった100文字ですが、ここに何を入れるかでオーガニック流入ははっきり変わります。問題は、私が運営している壁紙系アプリが40本ほどあり、それぞれに日本語・英語・スペイン語・ドイツ語・ポルトガル語・繁体字中国語のキーワードを用意しようとすると、40本 × 6ロケール = 240枠を手作業で埋めることになる、という点でした。
これまでは正直、日本語と英語の2ロケールだけ埋めて、残りは空欄か英語の使い回しで放置していました。海外ユーザーが収益の半分以上を占めているのに、です。1997年、16歳でインターネットに触れて「言語が違っても技術でつながれる」と感じたあの感覚を思い出すたびに、キーワード欄を母国語で整える作業を後回しにしているのは矛盾しているな、と引っかかっていました。
そこで2026年4月末、Gemini 2.5 Flash にキーワードの下書きを任せる仕組みを組みました。本記事は、その1ヶ月の運用ログと所感の整理です。完成された最適解ではなく、つまずいた点をそのまま残します。
なぜ Pro ではなく Flash を選んだか
最初に Gemini 2.5 Pro で試したのですが、キーワード生成というタスクには明らかにオーバースペックでした。1枠あたりの入力はアプリ名・カテゴリ・特徴を箇条書きにした200トークン程度で、出力も100文字以内。推論の深さより、240枠を安く速く回せることのほうが重要です。
実測で、Flash は1枠あたりの応答が1秒前後、Pro は3〜5秒でした。240枠を一晩のバッチで回すなら、この差は無視できません。コストも Flash のほうが大幅に安く、240枠を複数回試行しても費用は数十円に収まりました。品質面でも、後述する構造化出力と検証を組み合わせれば Flash で十分実用範囲でした。私は「タスクの難易度に対してモデルを過剰に盛らない」ことを、現場では一つの規律にしています。
キーワードは「自由作文」ではなく構造化出力で受け取る
最初の失敗は、プロンプトに「カンマ区切りで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文字に詰める」を確定的なコードで処理できます。生成の不確実性をモデルに残さず、編集可能な素材として受け取るのがコツでした。
CJK のバイト数で何度も足をすくわれた
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)公式ヘルプには「キーワードはカンマ区切り、スペース不要」と書かれていますが、ここで素直にスペースを入れてしまうと貴重な100文字を浪費します。Gemini の出力にもスペースが混ざることがあるので、pack の段階で除去しています。細かい話ですが、100文字しかない世界では1文字が効きます。
タイトル・サブタイトルとの重複排除が一番効いた
ASO で最も効果が出たのは、実は凝ったキーワード生成ではなく、地味な重複排除でした。App Store はアプリ名・サブタイトル・キーワード欄を合わせて索引します。つまりタイトルに「壁紙」と入っているなら、キーワード欄にもう一度「壁紙」と入れるのは100文字の無駄遣いです。
そこで、各アプリのタイトルとサブタイトルを形態素分割して除外語リストを作り、Gemini に「この語は使わないでほしい」と渡したうえで、生成後にもう一度こちら側でフィルタしました。二重に守ったのは、モデルへの指示だけだと2割ほどは除外語が混ざって戻ってきたからです。指示と検証は両輪で考えるのが安全だと、改めて感じました。
この重複排除だけで、空いた文字数に検索ボリュームのある別語を入れられるようになり、1本あたり平均で3〜4語ぶんの「枠」を取り戻せました。
禁止語とブランド名の取り扱い
もう一つの落とし穴が、競合のアプリ名や商標を入れてしまうことです。Gemini は素直なので、特徴に「人気の◯◯風」のような文脈があると、他社ブランド名を検索語として提案してくることがありました。これは審査リジェクトや法的リスクにつながるため、ブランド名・他社アプリ名の簡易ブロックリストを用意し、生成後に必ず弾くようにしています。
ここはモデルの賢さに任せてはいけない領域です。私は「人間の判断が法的・規約的に必要な箇所は、必ず確定的なコードで最後の砦を作る」ことにしています。AI に下書きさせること自体は効率的でも、最終責任は運営者にあるからです。
1ヶ月運用して残った所感
正直なところ、キーワードを変えてから1ヶ月で売上が劇的に伸びた、という派手な話はありません。壁紙という競合の多いカテゴリでは、1ヶ月でのインプレッション変動は季節要因やフィーチャーの影響と切り分けにくいのが実情です。それでも、これまで英語使い回しだったロケールに母国語キーワードを入れたアプリでは、検索経由の表示回数が緩やかに増える傾向が見えました。
何より大きかったのは、240枠という「人力では永遠に手をつけない作業」が、現実的な工数で回せるようになったことです。Gemini に下書きさせ、自分のコードで詰めて検証し、最後に目で確認する。この分業が定着したことで、ロケール追加の心理的なハードルがほぼ消えました。
技術そのものより、「やらないまま放置していた仕事を、やれる仕事に変えてくれた」ところに価値を感じています。次は、ロケールごとの実際の検索ボリュームデータを取り込んで、語の優先順位付けまで自動化したいと考えています。同じように多ロケール展開で手が止まっている個人開発の方の参考になれば幸いです。