ある年の春、壁紙アプリの日本語ロケールのキーワード欄を久しぶりに開いて、手が止まりました。100文字の枠に対して、使っていたのは29文字。しかもその中に、アプリ名にすでに入っている語が二つ紛れ込んでいました。
数年にわたって少しずつ書き換えてきた結果です。誰かが間違えたわけではなく、ロケールが増えるにつれて全体を見渡す手段がなくなっただけでした。16言語ぶんの欄を目視で確かめるという運用が、そもそも成り立っていなかったのです。
その日から、キーワード欄は「考えて書くもの」ではなく「機械で守るもの」だと考えを変えました。ここでは、その検査スクリプトの中身と、それ以前に積み上がっていた判断の履歴を、順に書いていきます。
App Store のキーワードは「160文字の予算」である
前提から確認します。iOS の検索対象になるテキスト欄は、思っているより狭いです。
合計160文字。ここに載らなかった語は、説明文にいくら書いても検索の入り口にはなりません。iOS の説明文にキーワードを詰め込む作業に意味がないのは、この一点に尽きます。Google Play は説明文の語も拾う設計ですので、同じ原稿を両ストアに流用すると、片方だけが空振りします。
そして重要なのが、name と subtitle の語は別枠で索引されている という点です。つまりキーワード欄に同じ語を書き直すと、100文字の予算をそのぶん捨てることになります。
もうひとつ、カンマの直後に空白を入れると、その空白が1文字として数えられます。7個のキーワードを空白付きで区切れば、それだけで6文字が消えます。6文字あれば、日本語なら短いキーワードが1つ入る量です。
この3点は仕様として安定しているぶん、機械で検査するのに向いています。
160文字の予算を検査するスクリプトを書く
ロケールごとのメタデータを JSON にまとめ、上の規則に照らして検査する Node スクリプトを用意しました。依存パッケージはありません。
#!/usr/bin/env node
// aso-keyword-lint.mjs — App Store Connect のローカライズ済みメタデータを
// 「name 30 / subtitle 30 / keywords 100」の予算に対して機械検査する。
import { readFileSync } from 'node:fs' ;
const LIMITS = { name: 30 , subtitle: 30 , keywords: 100 };
// Apple の文字数カウントに合わせ、サロゲートペアを1文字として数える
const len = ( s ) => [ ... (s ?? '' )]. length ;
// 比較用の正規化。全角/半角・大文字小文字・前後空白を吸収する
const norm = ( s ) => (s ?? '' ). normalize ( 'NFKC' ). toLowerCase (). trim ();
// name / subtitle 側の「すでに索引されている語」を取り出す
const wordsOf = ( s ) =>
norm (s)
. split ( / [\s,、・/|+\-–—::()()] + / u )
. filter (( w ) => w. length > 1 );
function lintLocale ( locale , meta ) {
const errors = [];
const warnings = [];
for ( const field of [ 'name' , 'subtitle' , 'keywords' ]) {
const n = len (meta[field]);
if (n > LIMITS [field]) {
errors. push ( `${ field }: ${ n } 文字(上限 ${ LIMITS [ field ] }/${ n - LIMITS [ field ] } 文字超過)` );
}
}
const raw = meta.keywords ?? '' ;
// 1) カンマ直後の空白は1文字ずつ課金される
const spaceWaste = (raw. match ( /, \s / g ) ?? []). length ;
if (spaceWaste > 0 ) {
errors. push ( `keywords: カンマ直後の空白 ${ spaceWaste } 個(${ spaceWaste } 文字を無駄に消費)` );
}
// 2) 空トークン・末尾カンマ
const tokens = raw. split ( ',' ). map (( t ) => t. trim ());
const empties = tokens. filter (( t ) => t === '' ). length ;
if (empties > 0 ) errors. push ( `keywords: 空のトークン ${ empties } 個(連続カンマ/末尾カンマ)` );
const live = tokens. filter (( t ) => t !== '' );
// 3) keywords 内での重複
const seen = new Map ();
for ( const t of live) {
const k = norm (t);
seen. set (k, (seen. get (k) ?? 0 ) + 1 );
}
const dupes = [ ... seen. entries ()]. filter (([, c ]) => c > 1 ). map (([ k ]) => k);
if (dupes. length ) errors. push ( `keywords: 重複トークン ${ dupes . join ( ' / ' ) }` );
// 4) name / subtitle と重複した語(別枠で索引されるため予算の二重払いになる)
const indexed = new Set ([ ... wordsOf (meta.name), ... wordsOf (meta.subtitle)]);
const redundant = live. filter (( t ) => indexed. has ( norm (t)));
if (redundant. length ) {
const saved = redundant. reduce (( a , t ) => a + len (t) + 1 , 0 );
errors. push ( `keywords: name/subtitle と重複 ${ redundant . join ( ' / ' ) }(回収可能 約${ saved } 文字)` );
}
// 5) 予算の使い残し
const used = len (raw);
const left = LIMITS .keywords - used;
if (left >= 15 ) {
warnings. push ( `keywords: ${ left } 文字が未使用(予算の ${ Math . round (( left / LIMITS . keywords ) * 100 ) }%)` );
}
const note = `索引対象 ${ len ( meta . name ) }+${ len ( meta . subtitle ) }+${ used } = ${ len ( meta . name ) + len ( meta . subtitle ) + used } / 160 文字` ;
return { locale, errors, warnings, note };
}
const path = process.argv[ 2 ];
if ( ! path) {
console. error ( 'usage: node aso-keyword-lint.mjs <metadata.json>' );
process. exit ( 2 );
}
const data = JSON . parse ( readFileSync (path, 'utf8' ));
let failed = 0 ;
for ( const [ locale , meta ] of Object. entries (data)) {
const r = lintLocale (locale, meta);
const mark = r.errors. length ? 'NG' : r.warnings. length ? '--' : 'OK' ;
console. log ( `[${ mark }] ${ locale } ${ r . note }` );
for ( const e of r.errors) console. log ( ` ERROR ${ e }` );
for ( const w of r.warnings) console. log ( ` WARN ${ w }` );
if (r.errors. length ) failed += 1 ;
}
console. log ( ` \n ${ Object . keys ( data ). length } ロケール検査 / ${ failed } ロケールでエラー` );
process. exit (failed > 0 ? 1 : 0 );
入力はロケールをキーにした素直な JSON です。App Store Connect の画面からコピーしても、あとで触れる API から吐き出しても、同じ形に落とせます。
{
"en-US" : {
"name" : "Beautiful HD Wallpapers" ,
"subtitle" : "4K backgrounds for your phone" ,
"keywords" : "aesthetic, live wallpaper, wallpapers, lock screen, 4k, nature, minimal"
},
"ja" : {
"name" : "壁紙アプリ HD" ,
"subtitle" : "高画質の背景画像を毎日お届け" ,
"keywords" : "壁紙,無料,おしゃれ,ロック画面,和風,自然,高画質,壁紙"
},
"zh-Hans" : {
"name" : "高清壁纸" ,
"subtitle" : "每日更新的手机背景图片" ,
"keywords" : "免费壁纸,动态壁纸,锁屏,无广告,风景"
},
"ar" : {
"name" : "خلفيات جميلة" ,
"subtitle" : "خلفيات عالية الدقة لهاتفك" ,
"keywords" : "خلفيات,مجاني,شاشة القفل,طبيعة,"
}
}
このサンプルは、私が実際にやってしまった間違いをそのまま4種類詰め込んであります。英語には空白区切りと二重登録、日本語には同一トークンの重複、アラビア語には末尾カンマ。中国語だけが素直な状態です。
Node v22.23.2 で実行した出力が次のものです。
$ node aso-keyword-lint.mjs metadata.sample.json
[NG] en-US 索引対象 23+29+71 = 123 / 160 文字
ERROR keywords: カンマ直後の空白 6 個(6 文字を無駄に消費)
ERROR keywords: name/subtitle と重複 wallpapers / 4k(回収可能 約14 文字)
WARN keywords: 29 文字が未使用(予算の 29%)
[NG] ja 索引対象 8+14+29 = 51 / 160 文字
ERROR keywords: 重複トークン 壁紙
WARN keywords: 71 文字が未使用(予算の 71%)
[--] zh-Hans 索引対象 4+11+19 = 34 / 160 文字
WARN keywords: 81 文字が未使用(予算の 81%)
[NG] ar 索引対象 12+25+30 = 67 / 160 文字
ERROR keywords: 空のトークン 1 個(連続カンマ/末尾カンマ)
ERROR keywords: name/subtitle と重複 خلفيات(回収可能 約7 文字)
WARN keywords: 70 文字が未使用(予算の 70%)
4 ロケール検査 / 3 ロケールでエラー
$ echo $?
1
英語ロケールでは、空白6文字と二重登録14文字で、合わせて20文字ぶんが取り戻せる計算になります。100文字の枠に対して20文字ですから、無視できる量ではありません。
終了コードが 1 になりますので、メタデータを更新する CI ジョブの手前に置けば、そのまま門番として使えます。
日本語ロケールで、最初の実装が取りこぼしたもの
ところが、上の出力をよく見ると、日本語ロケールの検出が一つ足りていません。
サンプルの日本語は name が「壁紙アプリ HD」、subtitle が「高画質の背景画像を毎日お届け」です。キーワード欄には「壁紙」と「高画質」が入っていますので、どちらも二重登録のはずです。しかし出力に上がってきたのは、キーワード欄内の「壁紙」の重複だけでした。
原因は wordsOf() にあります。空白や記号で分割して語を取り出す実装ですので、「壁紙アプリ」は一つの語として扱われ、その中の「壁紙」には届きません。英語やアラビア語のように語が空白で切れる言語では正しく動き、日本語と中国語でだけ静かに素通りしていたわけです。
言語ごとに挙動が変わる不具合の中でも、これは気づきにくい部類でした。エラーが出ないので、検査を通ったつもりになってしまいます。
対処として、CJK を含むトークンに限って、name と subtitle を連結した文字列に対する包含判定を足しました。
// 日本語・中国語は語の区切りが空白ではないため、トークン一致だけでは取りこぼす。
// 該当ロケールでは name/subtitle の文字列そのものに対する包含判定を併用する。
const CJK = / [ \u3040 - \u30ff\u3400 - \u9fff ] / u ;
const indexed = new Set ([ ... wordsOf (meta.name), ... wordsOf (meta.subtitle)]);
const surface = norm ( `${ meta . name ?? ''} ${ meta . subtitle ?? ''}` );
const redundant = live. filter (( t ) => {
const k = norm (t);
if (indexed. has (k)) return true ;
return CJK . test (k) && k. length >= 2 && surface. includes (k);
});
1文字のトークンを除外しているのは、「和」や「花」のような1文字が name のどこかに偶然含まれる誤検出を避けるためです。同じ理由で、包含判定は CJK を含むトークンだけに限定しています。英語でこれをやると、art が smart に一致してしまいます。
差し替え後の実行結果です。
$ node aso-keyword-lint.mjs metadata.sample.json
[NG] ja 索引対象 8+14+29 = 51 / 160 文字
ERROR keywords: 重複トークン 壁紙
ERROR keywords: name/subtitle と重複 壁紙 / 高画質 / 壁紙(回収可能 約10 文字)
WARN keywords: 71 文字が未使用(予算の 71%)
「高画質」が拾えるようになりました。日本語ロケールでは、これで10文字が回収できます。使っていた29文字のうち10文字ですので、実質的には3分の1が無駄だったことになります。
なお、この包含判定は形態素解析の代わりにはなりません。「壁紙」と「壁紙集」のような部分一致の扱いは Apple 側の索引がどうなっているか公表されていませんので、あくまで「疑わしい箇所を人間に見せる」ための検出だと考えています。断定して自動修正させる種類の処理ではありません。
日本語のキーワード欄そのものの組み立て方については、App Storeキーワードフィールドを100文字で最大化する設計術 に、区切り方と語の選び方を分けて書いています。
ストアフロント単位で見ると、枠は二つ並んでいます
ここまではロケールを一つずつ検査してきました。けれど利用者が立っているのは、ロケールではなく国のストアです。そして一つのストアは、たいてい二つのロケールを同時に索引します。
日本のストアなら日本語と英語(米国)、中国本土のストアなら簡体字中国語と英語(イギリス)、米国のストアなら英語(米国)とスペイン語(メキシコ)——という組が、国ごとに決まっています。組の片方を用意していなければ、その国で使えるはずの100文字が、手つかずのまま残ります。
私がこの並びを意識したのは、中国本土のストアで英語の語からの流入がまったく立たない理由を探していたときでした。英語は en-US しか用意しておらず、中国本土のストアが見ているのは en-GB のほうでした。用意していない欄に語が入っているはずもなく、原因は語の選び方でも実装でもなく、欄そのものが存在していなかったのです。
もう一つ、組の中で同じ語を二度置いても、その語が二倍効くということはありません。索引は語を一度だけ数えます。つまり en-US と es-MX の両方に aesthetic を入れた時点で、片方の10文字は捨てているのと変わりません。借用語ほどこれが起きます。スペイン語のキーワードを自分で考えていると、英語のまま通じる語は自然と手が伸びるからです。
組の重複と、空いている側の欄を、先ほどと同じ JSON から検査します。
#!/usr/bin/env node
// aso-storefront-lint.mjs — ストアフロント単位で「索引される二つのロケール」を一組と見なし、
// 組の中の重複と、用意していない側の欄を報告する。
import { readFileSync } from 'node:fs' ;
// 各ストアフロントが索引するロケール。App Store Connect のサポート言語一覧から
// 自分が配信している国だけを写す。表は年に一度は見直す。
const STOREFRONTS = {
US: [ 'en-US' , 'es-MX' ],
JP: [ 'ja' , 'en-US' ],
CN: [ 'zh-Hans' , 'en-GB' ],
DE: [ 'de-DE' , 'en-GB' ],
};
const LIMIT = 100 ; // keywords 欄だけを対象にする
const len = ( s ) => [ ... (s ?? '' )]. length ;
const norm = ( s ) => (s ?? '' ). normalize ( 'NFKC' ). toLowerCase (). trim ();
const tokensOf = ( s ) => (s ?? '' ). split ( ',' ). map (norm). filter (( t ) => t !== '' );
const CJK = / [-ヿ㐀-鿿] / u ;
function lintPair ( locales , data ) {
const present = locales. filter (( l ) => data[l]);
const missing = locales. filter (( l ) => ! data[l]);
const lines = [];
const used = present. reduce (( a , l ) => a + len (data[l].keywords), 0 );
const budget = locales. length * LIMIT ;
lines. push ( `索引される枠 ${ budget } 文字中 ${ used } 文字を使用(${ present . join ( ' + ' ) || 'なし'})` );
for ( const l of missing) {
lines. push ( `EMPTY ${ l } が未ローカライズ(このストアフロント向けに使える ${ LIMIT } 文字を持っていません)` );
}
// 組の中の重複。CJK は包含判定を併用する(前章と同じ理由)
if (present. length === 2 ) {
const [ a , b ] = present;
const surfaceB = norm ( `${ data [ b ]. name ?? ''} ${ data [ b ]. subtitle ?? ''} ${ data [ b ]. keywords ?? ''}` );
const setB = new Set ( tokensOf (data[b].keywords));
const dup = tokensOf (data[a].keywords). filter (
( t ) => setB. has (t) || ( CJK . test (t) && t. length >= 2 && surfaceB. includes (t)),
);
if (dup. length ) {
const saved = dup. reduce (( s , t ) => s + len (t) + 1 , 0 );
lines. push ( `DUP ${ a } と ${ b } で重複 ${ dup . join ( ' / ' ) }(同一組では加点されません/回収可能 約${ saved } 文字)` );
}
}
return lines;
}
const path = process.argv[ 2 ];
if ( ! path) {
console. error ( 'usage: node aso-storefront-lint.mjs <metadata.json>' );
process. exit ( 2 );
}
const data = JSON . parse ( readFileSync (path, 'utf8' ));
for ( const [ store , locales ] of Object. entries ( STOREFRONTS )) {
const [ head , ... rest ] = lintPair (locales, data);
console. log ( `[${ store }] ${ head }` );
for ( const l of rest) console. log ( ` ${ l }` );
}
入力は前章と同じ JSON です。先ほど検出された空白と二重登録を en-US から取り除き、es-MX を書き足した状態で実行します。
"en-US" : {
"name" : "Beautiful HD Wallpapers" ,
"subtitle" : "4K backgrounds for your phone" ,
"keywords" : "aesthetic,live wallpaper,lock screen,nature,minimal"
},
"es-MX" : {
"name" : "Fondos de Pantalla HD" ,
"subtitle" : "Imagenes 4K para tu telefono" ,
"keywords" : "aesthetic,fondos gratis,pantalla de bloqueo,naturaleza,minimal"
}
$ node aso-storefront-lint.mjs metadata.sample.json
[US] 索引される枠 200 文字中 113 文字を使用(en-US + es-MX)
DUP en-US と es-MX で重複 aesthetic / minimal(同一組では加点されません/回収可能 約18 文字)
[JP] 索引される枠 200 文字中 73 文字を使用(ja + en-US)
[CN] 索引される枠 200 文字中 19 文字を使用(zh-Hans)
EMPTY en-GB が未ローカライズ(このストアフロント向けに使える 100 文字を持っていません)
[DE] 索引される枠 200 文字中 0 文字を使用(なし)
EMPTY de-DE が未ローカライズ(このストアフロント向けに使える 100 文字を持っていません)
EMPTY en-GB が未ローカライズ(このストアフロント向けに使える 100 文字を持っていません)
米国の組では、借用語が二つ重なって18文字を落としています。中国本土の組は、日本語や英語の欄をどれだけ磨いても届かない場所に、100文字の欄が一つ空いたままです。ドイツに至っては、200文字のうち一文字も置いていません。
同じ組に、同じ語は置きません。片方が空いている組では、語を選ぶより先に欄を作ります。 ロケールを増やすかどうかを迷ったときは、この二つだけを見て決めるようにしています。
ひと言だけ添えておきます。この対応表は、Apple が索引の仕様として公表しているものではありません。App Store Connect のサポート言語一覧と、実際にストアの表示言語を切り替えて確かめた結果から、私は手元の表を作っています。国を足すときは、その国のストアで自分のアプリがどの言語で表示されるかを一度見てから書き足す——遠回りに見えて、思い込みで16言語ぶん間違えるよりはるかに早い手順でした。
英語の直訳が通用しなかった話
機械検査で守れるのは「枠の使い方」までです。そこに何を入れるかは、別の問題として残ります。
アプリを多言語で出し始めた頃、私は英語で機能していたキーワードをそのまま各言語に訳して入れていました。「Live Wallpaper」が英語圏で効いていたので、中国語には「动画壁纸」、日本語には「ライブ壁紙」、韓国語には「라이브 배경화면」と置いたわけです。
結果は芳しくありませんでした。ひと月待っても、これらの語からの流入はほとんど動きません。順位表を見ても、該当のキーワードでは圏外のままでした。
理由は後から分かりました。その言語圏の利用者が、そもそも「Live Wallpaper」という概念で検索していなかった のです。
中国語で実際に多かったのは「免费壁纸」(無料の壁紙)や「高清壁纸」(高画質の壁紙)という、技術用語ではなく利用者の目的をそのまま言い表した語でした。日本語も同じで、「ライブ壁紙」という機能名よりも「壁紙 無料」「おしゃれ 壁紙」のほうが、はるかに手前にありました。
このとき腹に落ちたのが、翻訳と最適化は別物だということです。訳した語は「私たちが提供しているもの」の名前であって、「利用者が探しているもの」の名前とは限りません。この二つがずれている限り、どれだけ枠を綺麗に埋めても検索には現れません。
以来、翻訳結果はそのまま入れず、一度「この言葉で探す人がいるか」を確かめる工程を挟むようにしました。機械翻訳の精度は当時より格段に上がっていますが、上がったのは訳文の自然さであって、需要の有無を教えてくれる機能がついたわけではありません。
候補語をどう作るか — 翻訳とLLMは「候補生成」までの道具
直訳が通用しなかったからといって、翻訳を使わないという話ではありません。使う位置を変えるだけです。
私の手順は3段階に分かれています。
第1段階は候補を広げる作業です。 英語で機能している語を DeepL と Google 翻訳の両方にかけ、訳の違うところをそのまま残します。2つの訳が割れた箇所は、たいてい「その言語で言い方が定まっていない概念」で、あとで検討する価値のある場所になります。訳が一致した箇所より、割れた箇所のほうが情報量があります。
第2段階は候補を増やす作業です。 ここで LLM を使います。プロンプトはこの程度のもので足ります。
壁紙アプリの日本語ロケール向けに、ロングテールのキーワード候補を20個挙げてください。
条件:
- 「壁紙」単体のような太い語は除く
- 2〜3語の組み合わせを中心にする
- 季節性のあるものと通年で使えるものを分けて示す
- それぞれ、どういう状況の人が検索するかを1行で添える
最後の1行が要点です。語だけを並べさせると、それらしい語彙の羅列が返ってきます。「どういう状況の人が検索するか」を添えさせると、生活の場面が想像できない候補は自分で弾けるようになります。私はこの1行を足してから、候補の採用率が明らかに上がりました。
第3段階は候補を捨てる作業です。 ここが本番です。第1・第2段階で出てきた候補を、そのロケールのストアで実際に検索し、上位に何が並ぶかを見ます。
意図と全く違うジャンルのアプリが並ぶ → その語は別の意味で使われています。候補から外します
大手が上位を占めている → 母数はあっても、入り込む余地がありません。保留します
同規模のアプリが並んでいる → 見込みのある候補として残します
翻訳も LLM も、この第3段階を代わりにやってはくれません。どちらも「言えそうな言い方」を出す道具であって、「探されている言い方」を知っているわけではないからです。ここを取り違えると、綺麗に訳されただけの、誰も検索していない語が16言語ぶん並びます。私が最初にやったのが、まさにそれでした。
言語圏ごとの傾向として見えてきたもの
十数言語を数年にわたって触っていると、いくつか傾向が見えてきます。ただしこれは私の運用しているジャンル(壁紙・ウェルネス系)での観察であって、法則として一般化できるものではありません。その前提で読んでいただければと思います。
価格を明示する語が効きやすい言語圏があります。 中国語の「免费」、日本語の「無料」、韓国語の「무료」は、私の記録の範囲では反応が明確でした。英語圏でも「free」は機能しますが、同じ語の競合の厚みが違います。
仕様を前面に出す語が効きやすい言語圏もあります。 ドイツ語・フランス語・スペイン語のロケールでは、「4K」「Ultra HD」といった解像度の表記を入れたときの動きのほうが、感覚的な形容よりも大きく出ました。
競合の薄い言語圏があります。 アラビア語とトルコ語は、話者数に対して同ジャンルのアプリが少なく、短いキーワードでも順位が付きやすい状態でした。ここは想定外の発見でした。
こうした傾向を「言語ごとの正解表」として固定してしまうと、市場の変化を見逃します。私は、傾向は仮説として持ちつつ、判断は必ずそのロケールの現在の順位表を見てから下すようにしています。
地域を1つずつ開けていく手順そのものについては、Rork アプリの多地域展開とASOを順番で設計する にまとめています。
RTL言語で表記が崩れていないかを確かめる
アラビア語やウルドゥー語のような RTL(右から左へ記述する)言語には、内容とは別の落とし穴があります。
機械翻訳の出力をコピーして App Store Connect に貼り付けたあと、実機のストアページで見ると、句読点の位置がおかしかったり、英数字を混ぜた箇所の並びが意図と違っていたりすることがあります。編集画面では正しく見えていても、レンダリングされる場所によって結果が変わります。
先ほどのスクリプトが末尾カンマを検出していたのも、実はこの流れで混入したものでした。RTL のテキストを扱っていると、カンマがどちら側に付いているのかが目視で判別しづらく、余分な区切りに気づけません。機械で数えるほうが確実です。
表記そのものについては、私は次の順で確認しています。
3つ目は見落としやすい箇所です。一覧では name と subtitle が途中で省略されますので、伝えたい語が省略された側に入っていると、実質的に読まれません。
複数ロケールの原稿を運用として整える話は、壁紙アプリの App Store ローカライズを多言語で運用して見えた所感 のほうに寄せてあります。
キーワード変更を記録して、効果を判定する
キーワードは変えた瞬間に結果が出るものではありません。ここを勘違いすると、判断がすべて雑になります。
私が使っているのは、スプレッドシート1枚の記録です。凝ったものではありません。
肝は「判定日をあらかじめ書いておく」ことです。変更した直後は毎日見てしまいますが、最初の数日の増減は、キーワードの効果というより単なるばらつきであることがほとんどでした。判定日を先に決めておくと、その間の数字に振り回されずに済みます。
外した語も必ず記録します。順位が下がったときに「何を入れたか」だけを見ていると、原因が「何を外したか」の側にあった場合に永久にたどり着けません。私はこれで一度、半年ほど遠回りをしました。
複数のバージョンを並行させて比べる方法もありますが、個人開発の規模では、そもそも母数が足りずに差が読めないことが多いです。私は並行比較よりも、1回ずつ変えて2週間ずつ待つ、という進め方に落ち着いています。遅いようでいて、後から履歴を読み返せるぶん、こちらのほうが判断が積み上がります。
季節枠とイベント枠を、通年枠と分けて持つ
壁紙のようなジャンルは、季節による需要の波が明確です。桜の壁紙を探す人は春にしかいませんし、その代わり春には確実にいます。
私はキーワードの枠を、あらかじめ二つに分けて考えています。
通年枠 : 季節を問わず一定の流入がある語。「壁紙 無料」「高画質」「おしゃれ」など
季節・イベント枠 : 期間が過ぎると機能しなくなる語。「桜」「紅葉」「クリスマス」「初詣」など
100文字の予算のうち、どこまでを入れ替え可能な枠にするかを先に決めておくと、季節の切り替えが単なる差し替え作業になります。決めていないと、毎回ゼロから考え直すことになり、そのうち更新自体が止まります。
入れ替えの時期は、需要の立ち上がりより早めに置くのが要点です。審査の待ち時間があり、さらに順位が動き始めるまでの時間もありますので、その月に入ってから変えたのでは間に合いません。桜であれば、2月のうちに入れておく必要があります。
イベント枠はさらに短期です。ハロウィンやクリスマスのように1〜2週間で終わる需要に対しては、入れることの是非も含めて考えるべきだと思っています。枠を1つ空けておく手間に見合うかどうかは、そのアプリの通年枠の強さ次第です。
通年枠がまだ育っていない段階では、季節枠に手を出さないほうが結果が良いと感じています。目安として、100文字のうち季節・イベント枠に充てるのは多くても3割程度に留めることをお勧めします。残りの7割が安定していないと、季節が終わった月の落ち込みをそのまま受けることになります。
ロングテールに寄せるという判断
もう一つ、早い段階で決めておくとよいのが、どの太さの語を狙うかです。
始めた当初、私は最も検索の多い語をそのまま狙っていました。「Wallpaper」のような1語のキーワードです。当然ながら、その枠の上位は大きなアプリで埋まっており、何ヶ月経っても順位表に載りませんでした。
考え方を変えたのは、2語・3語の組み合わせに移してからです。「Live Wallpaper Aesthetic」のように語を重ねると、検索の母数は減りますが、その語で競っている相手も一気に減ります。実際、母数の大きい語で圏外にいるより、母数の小さい語で上位に入っているほうが、流入は多くなりました。
この判断を言葉にすると、こうなります。
見るべきは検索の母数そのものではなく、母数 × その語で上位に入れる見込み です。
見込みの側は数値化しにくいので、私は「その語の上位10本を見て、自分のアプリが並んでも見劣りしないか」を目視で判断しています。厳密ではありませんが、母数だけを見て決めるよりは確実に外れが減りました。
なお、ここで狙う語が決まったら、それを160文字の予算にどう配置するかは、また別の作業です。name と subtitle は組み合わせの一部として索引されますので、キーワード欄に入れるのは「name と subtitle に入っていない語」だけで足ります。冒頭のスクリプトが検出しているのは、まさにこの配置のミスです。
使っているツールの現在地
ツールの話は、前提が変わりやすいので現状を整理しておきます。
長く使われていた App Annie は、2021年から2022年にかけて data.ai へ改称し、2024年3月に Sensor Tower に買収されました。data.ai のブランドは統合の過程で終了し、現在は Sensor Tower のプラットフォームに機能が寄せられています。古い記事や書籍で「App Annie で調べる」と書かれていても、そのままの名前では見つかりません。
外部ツールの価格やプラン構成は改定が頻繁ですので、金額は各社の現行ページで確認してください。私自身、以前の価格の感覚のまま検討して、話が噛み合わなかったことがあります。
順位を推定するツールと、App Store Connect が示す実測値は、性質が違います。前者は推定、後者は実測です。判断の根拠にするなら実測のほうを上に置くべきで、推定値は「他社がどうなっているか」を知るために使うもの、と分けて考えると迷いません。
ロケール数が増えてきたら、メタデータ自体をリポジトリで管理して API から流し込む形に移行するのが結局は早いです。その進め方は複数アプリのストア情報を App Store Connect API でコード管理する に分けて書いています。冒頭のスクリプトは、その流れの中に置くとちょうど検査工程になります。
最初に整える範囲を、意図的に狭くする
ここまで16言語を前提に書いてきましたが、最初からこの数を扱う必要はまったくありません。
私自身、最初の数年は英語・日本語・中国語の3ロケールだけで運用していました。3つに絞っていたので、変更したときの結果を最後まで追えましたし、ロケールごとの違いも読み取れました。数を増やしたのは、その読み取り方が身についてからです。
いま同じことを始めるなら、次のどれかから入ると思います。
そして、増やす前に必ずやっておくべきことが1つあります。いま持っているロケールのキーワード欄が、160文字の予算をきちんと使い切っているかを確かめること です。
私が最初に開いた日本語ロケールは、100文字のうち29文字しか使っておらず、使用率にして29%でした。さらにそのうち10文字は二重登録でしたので、実際に効いていたのは19文字、19%だけということになります。
この状態でロケールを16に増やしても、同じ穴が16個に増えるだけです。順番が逆でした。増やす前に、既存を検査します。この場合は、それ以外に取るべき順序がありません。
次にやること
この記事の中で、いちばん手を動かしやすいのは冒頭のスクリプトです。手順としてはこうなります。
いま配信しているアプリの全ロケールについて、name・subtitle・keywords を JSON に写します
node aso-keyword-lint.mjs metadata.json を実行し、ERROR が出たロケールを一覧にします
node aso-storefront-lint.mjs metadata.json を実行し、組の片方しか持っていない国を書き出します
回収できた文字数ぶんだけ、キーワードを足します。この時点では新しい語を考えず、既存の候補から入れます
実装日と判定日を記録し、2週間後まで数字を見ません
4の「新しい語を考えない」は意図的です。枠の整理と語の入れ替えを同時にやると、結果が動いたときにどちらが効いたのか分からなくなります。まず穴を塞ぎ、それから中身を考えます。この順番にしてから、私の判断は目に見えて速くなりました。
多言語のASOは、語学力の勝負ではなく、記録と検査の勝負でした。少なくとも私にとっては、そう考えたほうが手が動きます。お読みいただきありがとうございました。