GooglePlayの掲載情報のローカライゼーションを自動化する

# はじめに
GooglePlayの**タイトル**や**説明文**は検索順位に大きな影響を及ぼすことでよく知られています。ターゲットを日本人に絞っている場合を除いて、ほとんどの開発者が**英語**と**日本語**の説明文を用意していると思います。ユーザーは母国語でアプリを検索するので、**タイトル**や**説明文**をローカライズすることはASO(App Store Optimization)の観点からとても意味のあることです。
予算に余裕があるなら `Developer Console` から翻訳を購入したり[Gengo](https://gengo.com/ja/)などのサービスを使ってプロの翻訳家に依頼したいところですが、小遣い制のサラリーマンにはなかなか難しいところです。
少しぐらいおかしな文章であったとしても、説明文にそれぞれの国の人が検索するであろうキーワードが含まれてさえいればASOとしては効果があるので、Google翻訳を使って翻訳した説明文をコピペする方法がローカライゼーションの第一歩になるでしょう。

# Google翻訳を使ったローカライゼーションの効果
GooglePlayでのダウンロード数は検索ランキングやレビューサイトで紹介されたなど、様々な要因が影響するため、正確に効果を計測するのは難しいですが、私の場合、約100ダウンロード/日だったのが、説明文を69ヶ国語にローカライズしたことで約2倍の200ダウンロード/日に増やすことができました。効果はアプリのジャンルや実際の説明文などによって大きく変わってくると思いますが、私の観測では効果は間違いなくあります。

# ローカライゼーションのデメリット
+ アプリがローカライズされていないという理由での低評価のレビューが増えます。
+ 英語以外の言語で問い合わせが増えるのでサポートのコストが増えます。
+ Google翻訳しても意味の理解できないレビューが増えます。それがバグ報告だったり要望だったりすると気になって眠れません。日本語に翻訳するよりも英語に翻訳したほうが意味が分かることも多いです。
+ アプリがRTL言語に対応していないと、苦情のレビューが増えます。

![rating.png](https://qiita-image-store.s3.amazonaws.com/0/30511/f638ef09-d221-d70a-28a5-ab6e07f92ea8.png)


私がリリースしているのは電卓アプリなので、基本的に世界中の誰でも使える説明不要なアプリです。複雑な操作や設定などが必要なアプリでは、アプリがローカライズされていないことで、より酷いレビューが増える可能性があるので注意したほうがいいでしょう。

# 問題点
ローカライズの対象となる言語が多すぎて手作業でやるのは非常に大変です。英語、日本語を除いた対象となる言語は実に69言語にのぼります。さらに私のリリースしている電卓アプリ [CalcNote](https://play.google.com/store/apps/details?id=com.burton999.notecal)、[CalcNotePro](https://play.google.com/store/apps/details?id=com.burton999.notecal.pro) は無料版と有料版でアプリを分けているので作業量はさらに2倍になります。一度全てを手作業でやってみましたが、トータルで1時間以上かかり泣きそうになりました。

# 解決策
Googleが提供しているAPIを使うことで翻訳からストアの掲載情報の更新まで全て自動化することが可能です。自動化の手順は以下の3ステップから構成されます。

1. `Google Spreadsheet`で翻訳
2. `Google Sheets API v4`を使ってプログラムから翻訳されたテキストを取得して加工
3. `Android Publisher API`を使ってGooglePlayの掲載情報を更新

今回はビルドツールにMavenを使ってコンソールから実行するタイプのJavaアプリとして作成しましたが、SDKは様々な言語用に提供されているので、使い慣れた環境のものを使うのが良いでしょう。
それでは各ステップを順番に見ていきます。

## 1.翻訳
翻訳には`Google Spreadsheet`の[GOOGLETRANSLATE関数](https://support.google.com/docs/answer/3093331?hl=ja)を使います。今回、私が実際に使用した[Spreadsheet](https://docs.google.com/spreadsheets/d/1wIyk1Re18cjUzLNm1XUi4nu4CaZh_IGFG37biddu1Eg/edit?usp=sharing)をサンプルとして[共有](https://docs.google.com/spreadsheets/d/1wIyk1Re18cjUzLNm1XUi4nu4CaZh_IGFG37biddu1Eg/edit?usp=sharing)しておきます。
シートにある各行の意味は以下の通りです。

+ `B列`にあるのが翻訳のベースとなる英語の説明文です。この内容がそれぞれの言語に自動翻訳されます。今回は翻訳後のテキストだけでは不安なので、翻訳後のテキストとベースの英語の説明文も併用して表示するようにします。
+ `GoogleTranslate-lang`は`GOOGLETRANSLATE関数`の引数に指定する言語コードです。
+ `GooglePlay-lang`は`Android Publisher API`で掲載情報を更新する時に指定する言語コードです。
+ `lang`は言語の説明でプログラムからは使用しません。
+ `Title-Length`, `Short Description-Length`, `Long Description-Length`はそれぞれ**タイトル**、**簡単な説明**、**詳しい説明**の文字数です。それぞれ**30文字**、**80文字**、**4000文字**という制限があります。
+ `Title`は掲載情報の**タイトル**です。Google翻訳で自動化すると簡単に30文字の制限を超えてしまうので、`GOOGLETRANSLATE関数`を使わず手作業で調整しました。(**電卓**という意味のキーワードが必ず含まれるように調整しました)
+ `Short Description`は掲載情報の**簡単な説明**です。Google翻訳で自動化すると簡単に80文字の制限を超えてしまうので、`GOOGLETRANSLATE関数`を使わず手作業で調整しました。
+ `Long Description1`〜`Long Description8`は掲載情報の**詳しい説明**です。8個に分割しているのは意図せぬ翻訳を避けるためです。例えば`Long Description2`と`Long Description5`は**strongタグ**を使って強調表示したいのですが、これを`GOOGLETRANSLATE関数`に渡すと**strongタグ**も翻訳されてしまい強調タグとして機能しなくなります。

ちなみにこの`GOOGLETRANSLATE関数`を使った自動翻訳は私が考案したわけではなく、TwitterのTLに流れてきた情報を参考にしただけです。最初にこれを考えた人は天才ですね。感謝です。

## 自動化の準備
### Service Accountの作成と権限の付与
自動化するにあたり、プログラムから`Google Spreadsheet`と`GooglePlay Developer Console`にアクセスできる`Service Account`を作成する必要があります。

1. [ここ](http://www.sharkpp.net/blog/2016/09/22/how-to-use-google-spreadsheets-api-for-php.html)を参考にして新しいプロジェクトを作りサービスアカウントを作成します。今回は`CalcNoteTranslation`というプロジェクト名にしました。
1. 認証情報のJSONファイルをダウンロードします。このファイルはパブリックなバージョン管理システムにコミットしないように注意してください。
1. [Google Play Developer Console](https://play.google.com/apps/publish/?hl=JA)にアクセスして、先ほど作成したサービスアカウントに**リリースマネージャー**の権限を付与します。

### 依存ライブラリの追加
`Google Spreadsheet`と`GooglePlay Developer Console`にアクセスする為のライブラリを依存関係に追加します。

```xml:pom.xml
<dependency>
    <groupId>com.google.api-client</groupId>
    <artifactId>google-api-client</artifactId>
    <version>1.22.0</version>
</dependency>
<dependency>
    <groupId>com.google.apis</groupId>
    <artifactId>google-api-services-sheets</artifactId>
    <version>v4-rev108-1.22.0</version>
</dependency>
<dependency>
    <groupId>com.google.apis</groupId>
    <artifactId>google-api-services-androidpublisher</artifactId>
    <version>v2-rev40-1.22.0</version>
</dependency>
<dependency>
    <groupId>com.google.api-client</groupId>
    <artifactId>google-api-client-gson</artifactId>
    <version>1.22.0</version>
</dependency>
```

## 2.Google Spreadsheetから翻訳文の取得と加工
以下のコードでは`Google Spreadsheet`から言語毎に翻訳後のテキストを取得して、加工(改行の挿入、タグによる強調)して、ベースの英語の説明と結合して最終的な**詳しい説明**を生成しています。

```java:SpreadsheetService.java
public class SpreadsheetService {

    private static final String APPLICATION_NAME = "CalcNoteTranslation";
    // SpreadsheetのIDはURLに書いてあります。
    private static final String SPREADSHEET_ID = "1wIyk1Re18cjUzLNm1XUi4nu4CaZh_IGFG37biddu1Eg";
    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
    private static final List<String> SCOPES = Arrays.asList(SheetsScopes.SPREADSHEETS_READONLY);
    
    private static final int ROW_GOOGLE_TRANSLATE_LANG = 0;
    private static final int ROW_GOOGLE_PLAY_LANG = 1;
    private static final int ROW_LANG = 2;
    private static final int ROW_TITLE_LENGTH = 3;
    private static final int ROW_SHORT_DESCRIPTION_LENGTH = 4;
    private static final int ROW_LONG_DESCRIPTION_LENGTH = 5;
    private static final int ROW_TITLE = 6;
    private static final int ROW_SHORT_DESCRIPTION = 7;
    private static final int ROW_LONG_DESCRIPTION_01 = 8;
    private static final int ROW_LONG_DESCRIPTION_02 = 9;
    private static final int ROW_LONG_DESCRIPTION_03 = 10;
    private static final int ROW_LONG_DESCRIPTION_04 = 11;
    private static final int ROW_LONG_DESCRIPTION_05 = 12;
    private static final int ROW_LONG_DESCRIPTION_06 = 13;
    private static final int ROW_LONG_DESCRIPTION_07 = 14;
    private static final int ROW_LONG_DESCRIPTION_08 = 15;

    private static HttpTransport HTTP_TRANSPORT;
    static {
        try {
            HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
        } catch (Exception e) {
            HTTP_TRANSPORT = null;
        }
    }
    
    private final Sheets service;
    
    public SpreadsheetService() throws Exception {
        // credential.jsonは「自動化の準備」でダウンロードしたJSONファイルです。
        InputStream is = SpreadsheetService.class.getResourceAsStream("/credential.json");
        GoogleCredential credential = GoogleCredential.fromStream(is);
        credential = credential.createScoped(SCOPES);
        this.service = new Sheets.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential).setApplicationName(APPLICATION_NAME).build();
    }
    public Collection<GooglePlayTranslation> getGooglePlayTranslations(Version version) throws IOException {
        ValueRange valueRange = service.spreadsheets().values().get(SPREADSHEET_ID, version.getSheetName() + "!B1:BS16").execute();
        List<List<Object>> rows = valueRange.getValues();
        Map<Integer, GooglePlayTranslation> translations = new TreeMap<Integer, GooglePlayTranslation>();
        
        for (int row = 0; row < rows.size(); row++) {
            List<Object> cols = rows.get(row);
            for (int col = 0; col < cols.size(); col++) {
                GooglePlayTranslation translation = translations.get(col);
                if (translation == null) {
                    translation = new GooglePlayTranslation();
                    translations.put(col, translation);
                }
                Object value = cols.get(col);
                switch (row) {
                case ROW_GOOGLE_PLAY_LANG:
                    translation.setLocale(value.toString().trim());
                    break;
                case ROW_TITLE:
                    translation.setTitle(value.toString().trim());
                    break;
                case ROW_SHORT_DESCRIPTION:
                    translation.setShortDescription(value.toString().trim());
                    break;
                case ROW_LONG_DESCRIPTION_02:
                case ROW_LONG_DESCRIPTION_05:
                    translation.addLongDescription("\n" + "<strong>" + value.toString().trim() + "</strong>");
                    break;
                case ROW_LONG_DESCRIPTION_04:
                case ROW_LONG_DESCRIPTION_07:
                    translation.addLongDescription("\n" + value.toString().trim());
                    break;
                case ROW_LONG_DESCRIPTION_01:
                case ROW_LONG_DESCRIPTION_03:
                case ROW_LONG_DESCRIPTION_06:
                case ROW_LONG_DESCRIPTION_08:
                    translation.addLongDescription(value.toString().trim());
                    break;
                }
            }
        }
        return translations.values();
    }
}
```

```java:GooglePlayTranslation.java
public class GooglePlayTranslation {
    
    private static final int MAX_TITLE = 30;
    private static final int MAX_SHORT_DESCRIPTION = 80;
    private static final int MAX_LONG_DESCRIPTION = 4000;
    
    private String locale;
    private String title;
    private String shortDescription;
    private List<String> longDescriptions = new ArrayList<String>();
    public GooglePlayTranslation() {
    }
    public String getTitle() {
        return title;
    }
    public void setTitle(String title) {
        if (title.length() > MAX_TITLE) {
            throw new IllegalArgumentException("Title is too long. length=" + title.length());
        }
        this.title = title;
    }
    public String getShortDescription() {
        return shortDescription;
    }
    public void setShortDescription(String shortDescription) {
        if (shortDescription.length() > MAX_SHORT_DESCRIPTION) {
            throw new IllegalArgumentException("Short Description is too long. length=" + shortDescription.length());
        }
        this.shortDescription = shortDescription;
    }
    public void addLongDescription(String longDescription) {
        this.longDescriptions.add(longDescription);
    }
    public String getLocale() {
        return locale;
    }
    public void setLocale(String locale) {
        this.locale = locale;
    }
    public boolean isEnBase() {
        return this.locale.startsWith("en-base");
    }
    public String getLongDescription(GooglePlayTranslation us) {
        StringBuilder sb = new StringBuilder();
        for (String longDescription : this.longDescriptions) {
            sb.append(longDescription + "\n");
        }
        sb.append("\n");
        sb.append("\n");
        sb.append("\n");
        for (String longDescription : us.longDescriptions) {
            sb.append(longDescription + "\n");
        }
        if (sb.length() > MAX_LONG_DESCRIPTION) {
            throw new RuntimeException("Long Description is too long -- length=" + sb.length());
        }
        return sb.toString();
    }
}
```

## 3.掲載情報の更新
翻訳されたアプリの掲載情報を更新するコードです。最終的にcommitしなければ変更は反映されないので途中でエラーが発生しても安心です。commitまで成功すると審査中の状態になります。

```java:
public class GooglePlayService {
    
    private static final String APPLICATION_NAME = "CalcNoteTranslation";
    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
    private static HttpTransport HTTP_TRANSPORT;
    
    static {
        try {
            HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
        } catch (Exception e) {
            HTTP_TRANSPORT = null;
        }
    }
    
    private boolean DEBUG = false;
    private final AndroidPublisher service;
    
    public GooglePlayService() throws Exception {
        InputStream is = GooglePlayService.class.getResourceAsStream("/credential.json");
        GoogleCredential credential = GoogleCredential.fromStream(is);
        credential = credential.createScoped(AndroidPublisherScopes.all());
        this.service = new AndroidPublisher.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential).setApplicationName(APPLICATION_NAME).build();
    }
    
    public void update(Version version, Collection<GooglePlayTranslation> translations) throws Exception {
        GooglePlayTranslation base = null;
        Edits edits = this.service.edits();
        AppEdit edit = edits.insert(version.getPackageName(), null).execute();
        for (GooglePlayTranslation translation : translations) {
            if (translation.isEnBase()) {
                base = translation;
                continue;
            }
            if (base == null) {
                throw new RuntimeException("base language is null!!");
            }
            Listing listing = new Listing()
                .setTitle(translation.getTitle())
                .setShortDescription(translation.getShortDescription())
                .setFullDescription(translation.getLongDescription(base));
            edits.listings().update(version.getPackageName(), edit.getId(), translation.getLocale(), listing).execute();
            Thread.sleep(500);
        }
        Commit commit = edits.commit(version.getPackageName(), edit.getId());
        commit.execute();
        System.out.println("COMPLETED!!");
    }
    
}
```

## その他のコード
CalcNoteはFree版とPro版で別のアプリとして提供しているので、Enumで定義しています。

```java:Version.java
public enum Version {
    FREE("com.burton999.notecal", "Free"),
    PRO("com.burton999.notecal.pro", "Pro");
    private final String packageName;
    private final String sheetName;
    private Version(String packageName, String sheetName) {
        this.packageName = packageName;
        this.sheetName = sheetName;
    }
    public String getPackageName() {
        return packageName;
    }
    public String getSheetName() {
        return sheetName;
    }
}
```

最後にエントリーポイントのコードです。

```java:Main.java
public static void main(String[] args) throws Exception {
    Version v = Version.PRO;
    SpreadsheetService spreadsheet = new SpreadsheetService();
    GooglePlayService googleplay = new GooglePlayService();
    Collection<GooglePlayTranslation> translations = spreadsheet.getGooglePlayTranslations(v);
    googleplay.update(v, translations);
}
```

# 最後に
ストアの掲載情報はアプリに新機能を追加した場合や、ASOを行う時に頻繁に更新することになるので自動化するメリットはとても大きいです。一度、頑張って仕組みを構築すれば他のアプリにも転用できるので非常にオススメです。