App StoreのレビューをSlackに流すbotを作ってみた

はじめに

※ この記事は Tech KAYAC Advent Calendar 2015 11日目の記事です

はじめまして。「ぼくらの甲子園!ポケット」というスマフォゲームアプリの開発と運用を担当している@laoujiです。

サービスインしているゲームの運用をしているエンジニアにとってユーザの声を聞くことが非常に重要だと思います。 約半年前にApp StoreやGoogle Playのアプリレビューの内容を社内IRCに流すバッチスクリプトを作って、定時的にレビューを読むことを習慣にしています。

それから時間が経って、7日に@fujiwaraが説明したように、9月末ぐらいからチーム内のコミュニケーションをほぼ全部Slackに移行しました。

その作ったバッチスクリプトの結果がnopasteのマルチホスト対応のおかげで引き続きSlack上でも見れますが、Slackになるとやっぱりplain textがちょっとつまらなく見えるし、もうちょっとSlackっぽいもの作れるんじゃないかなと思うようになりました。

ということでjordgubbeというSlack向けwebhookを作ってみました。最近のレビューを引っ張ってこんな感じにSlackに流すツールです:

jordgubbe_sample.png

その作り方の話をさせてください。

iTunesのRSSフィード

App StoreのレビューはiTunesのフィードから取得できます。 アップル社が運用しているフィードですが、非公開でドキュメントなど存在しません。でも半年以上IRCに流すスクリプトは問題なく運用してきましたので、わりと安定している感じです。

フィードのエンドポイントは以下のようなurlになっています:

http://itunes.apple.com/jp/rss/customerreviews/page=1/id=アプリID/sortBy=mostRecent/xml

iTunesストアに公開されているアプリなら上記のようなurlを使ってその国のストアのレビューが取得できるはずです。 上記のurlの場合 .com/の後に /jp/があるので、日本のストアのものになります。/us/とか他の国のコードで置き換えることももちろん可能です。

page=1のところですが、フィードのレスポンスがレビュー50個のページに分かれていて、基本的に過去10ページまで取得可能です。 一応以下のような項目がフィードに埋め込んであるので、レビューが50個以上必要な時"next"のurlをクロールしながら過去のデーターを取りにいくといいでしょう。

<link rel="first" href="https://itunes.apple.com/jp/rss/customerreviews/page=1/id=アプリID/sortby=mostrecent/xml?urlDesc=/customerreviews/page=1/id=830062153/sortBy=mostRecent/xml"/>
<link rel="last" href="https://itunes.apple.com/jp/rss/customerreviews/page=10/id=アプリID/sortby=mostrecent/xml?urlDesc=/customerreviews/page=1/id=830062153/sortBy=mostRecent/xml"/>
<link rel="previous" href="https://itunes.apple.com/jp/rss/customerreviews/page=1/id=アプリID/sortby=mostrecent/xml?urlDesc=/customerreviews/page=1/id=830062153/sortBy=mostRecent/xml"/>
<link rel="next" href="https://itunes.apple.com/jp/rss/customerreviews/page=2/id=アプリID/sortby=mostrecent/xml?urlDesc=/customerreviews/page=1/id=830062153/sortBy=mostRecent/xml"/>

後は、最後のxmlのところをjsonに置き換えるとJSONのフィードもありますが、 なぜかJSONのフィードのほうがxmlにある項目が全て揃っていません。 レビューの日付などかなり重要な項目もJSONのフィードにはありません・・・

XMLのほうは以下のようなレビューデーターが取得できます:

<entry>
<updated>2015-12-04T14:28:00-07:00</updated>
<id>1295884497</id>
<title>自分自身がチームの個性になるゲーム</title>
<content type="text">
このゲームの他の野球ゲームとの違いは、1人が1チームをもってやるのではなく、最大15人が1チームの学校の1選手になって甲子園を目指すという点です。 1人が強かったとしても、相手がチーム一丸となって試合に参加できるようなチームなら勝てないという、本当に甲子園をめざすようなリアルシミュレーションゲームです。 8時、12時、19時、23時ごろ行われる試合に、参加できない環境なら、本来のゲームの面白さを感じられないかもですが、その時間をゲームに費やせるならこれほど面白い野球ゲームはないと思います。ぜひ1度プレイしてみてください                                            もし、このレビューが参考になったとお感じになられたら、このコードをゲーム内の友達紹介ページにコピぺしてください。 ゲームの課金ポイントがてにはいります。 SGC7ECYD
</content>
<im:contentType term="Application" label="アプリケーション"/>
<im:voteSum>0</im:voteSum>
<im:voteCount>0</im:voteCount>
<im:rating>5</im:rating>
<im:version>0</im:version>
<author>
<name>焙煎筋肉</name>
<uri>https://itunes.apple.com/jp/reviews/id479949249</uri>
</author>
<link rel="related" href="https://itunes.apple.com/jp/review?id=830062153&type=Purple%20Software"/>
<content type="html">
<table border="0" width="100%"> <tr> <td> <table border="0" width="100%" cellspacing="0" cellpadding="0"> <tr valign="top" align="left"> <td width="100%"> <b><a href="https://itunes.apple.com/jp/app/bokurano-jia-zi-yuan!     poketto/id830062153?mt=8&uo=2">自分自身がチームの個性になるゲーム</a></b><br/> <font size="2" face="Helvetica,Arial,Geneva,Swiss,SunSans-Regular"> </font> </td> </tr> </table> </td> </tr> <tr> <td> <font size="2" face="Helvetica,Arial,Geneva,Swiss,SunSans-Regular"><br/>このゲームの他の野球ゲームとの違いは、1人が1チームをもってやるのではなく、最大15人が1チームの学校の1選手になって甲子園を目指すという点です。<br/>1人が強かったとしても、相手がチーム一丸となって試合に参加できるようなチームなら勝てないという、本当に甲子園をめざすようなリアルシミュレーションゲームです。<br/>8時、12時、19時、23時ごろ行われる試合に、参加できない環境なら、本来のゲームの面白さを感じられないかもですが、その時間をゲームに費やせるならこれほど面白い野球ゲームはないと思います。ぜひ1度プレイしてみてください<br/><br/>もし、このレビューが参考になったとお感じになられたら、このコードをゲーム内の友達紹介ページにコピぺしてください。<br/>ゲームの課金ポイントがてにはいります。<br/>SGC7ECYD</font><br/> </td> </tr> </table>
</content>
</entry>

そしてJSONのほうは以下:


{
  "author":{
    "uri":{"label":"https://itunes.apple.com/jp/reviews/id479949249"}, 
    "name":{"label":"焙煎筋肉"}, 
    "label":""
  },
  "im:version":{"label":"0"}, 
  "im:rating":{"label":"5"}, 
  "id":{"label":"1295884497"}, 
  "title":{"label":"自分自身がチームの個性になるゲーム"},
  "content":{
    "label":"このゲームの他の野球ゲームとの違いは、1人が1チームをもってやるのではなく、最大15人が1チームの学校の1選手になって甲子園を目指すという点です。\n1人が強かったとしても、相手がチーム一丸となって試合に参加できるようなチームなら勝てないという、本当に甲子園をめざすようなリアルシミュレーションゲームです。\n8時、12時、19時、23時ごろ行われる試合に、参加できない環境なら、本来のゲームの面白さを感じられないかもですが、その時間をゲームに費やせるならこれほど面白い野球ゲームはないと思います。ぜひ1度プレイしてみてください\n\nもし、このレビューが参考になったとお感じになられたら、このコードをゲーム内の友達紹介ページにコピぺしてください。\nゲームの課金ポイントがてにはいります。\nSGC7ECYD",
    "attributes":{"type":"text"}
  },
  "link":{
      "attributes":{"rel":"related", "href":"https://itunes.apple.com/jp/review?id=830062153&type=Purple%20Software"}
  },
  "im:voteSum":{"label":"0"}, 
  "im:contentType":{"attributes":{"term":"Application", "label":"アプリケーション"}}, 
  "im:voteCount":{"label":"0"}
}

今回、レビューの日付もSlackに流したかったのでXMLのフィードのほうを使いました。

今まで使ってきた言語ではxmlのパージングってjsonと比較してちょっと面倒臭い感じありますが、 go言語のencoding/xmlがとても使いやすくて便利でした。

まず取りたいデーターを以下のようなstructで定義します:


import "encoding/xml"

type ReviewData struct {
    Entries []struct {
        Id      string   `xml:"id"`
        Updated string   `xml:"updated"`
        Title   string   `xml:"title"`
        Content []string `xml:"content"`
        Rating  int      `xml:"rating"`
        Author  struct {
            Name string `xml:"name"`
            Uri  string `xml:"uri"`
        } `xml:"author"`
    } `xml:"entry"`
}

Contentのタイプをsliceにしているのがxmlではtextとhtmlの2つのタイプが定義してあるからです。HTMLのほうは使う予定が特にないですが、これで両方の値が取得できます。

これでフィードをGETしてxml.Umarshalに渡すだけです。


uri := "http://itunes.apple.com/jp/rss/customerreviews/id=" + appId + "/sortBy=mostRecent/xml"

res, err := http.Get(uri)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()

rawXml, _ := ioutil.ReadAll(res.Body)

reviewData := ReviewData{}
err := xml.Unmarshal(rawXml, &reviewData)
if err != nil {
    log.Fatal(err)
}

Slackに投稿

今回作ったbotはcronを使って定時的にiTunesのフィードを叩いて、新着レビューをSlackに投稿するためのものなので、 Slackの incoming webhookでいい感じに実装できました。

SlackのRTM APIを使うと、シンプルなメッセージぐらいしか送れないんですが、webhookだったらAttachmentを使ってリッチなフォーマッティングを含めたメッセージも組み合わせることができます。

Slackに送るJSONのPayloadをこんな感じに組み合わせました:


type SlackPayload struct {
    Text        string            `json:"text"`
    UserName    string            `json:"username"`
    IconEmoji   string            `json:"icon_emoji"`
    Attachments []SlackAttachment `json:"attachments"`
}

type SlackAttachment struct {
    Title     string            `json:"title"`
    TitleLink string            `json:"title_link"`
    Text      string            `json:"text"`
    Fallback  string            `json:"fallback"`
    Fields    []AttachmentField `json:"fields"`
}

type SlackAttachmentField struct {
    Title string `json:"title"`
    Value string `json:"value"`
    Short bool   `json:"short"`
}

フィードのentryタグに関する注意点が一つですが、最初の項目だけはユーザのレビューではなく、 iTunesのプレビューページで表示されるアプリの説明や詳細データーなので、Entries[0]を丸ごとスキップしておきました。


attachments := []SlackAttachment{}

for i, entry := range reviewData.Entries {
    // first entry is the summary of the app so skip it
    if i == 0 {
        continue
    }

    updatedTime := time.Parse("2006-01-02T15:04:05-07:00", entry.Updated)

    fields := []SlackAttachmentField{}
    fields = append(fields, SlackAttachmentField{Title: "Rating", Value: strings.Repeat(":star:", review.Rating), Short: true})
    fields = append(fields, SlackAttachmentField{Title: "Updated", Value: updatedTime.Format("2006-01-02 15:04:05"), Short: true})

    attachments = append(attachments, SlackAttachment{
        Title:     entry.Title,
        TitleLink: entry.Author.Uri,
        Text:      entry.Content[0], //type=textのみ
        Fallback:  entry.Title + " " + entry.Author.Uri,
        Fields:    fields,
    })
}

jsonPayload, _ := json.Marshal(SlackPayload{
    UserName:    conf.BotName,
    IconEmoji:   conf.IconEmoji,
    Text:        conf.MessageText,
    Attachments: attachments,
})

最後にjson化したpayloadデーターをSlackのAPIにPOSTするだけです。


    slackUrl := "https://hooks.slack.com/services/dummy/dummy/dummy"

    req, _ := http.NewRequest("POST", slackUrl, bytes.NewBuffer([]byte(jsonPayload)))
    req.Header.Set("Content-Type", "application/json")

    client := http.DefaultClient
    res, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()

以下のようなメッセージができあがりました:

jordgubbe_slack_small.png

まとめ

App StoreのフィードとSlackのincoming webhookの紹介でした。

これで毎日ユーザの大事な意見をしっかり読んで、もっともっといいゲームアプリ作って行けそうですね!

明日

明日はトレンディな@ryusukefudaです!