Post

AppStore Reviews Bot|Automate APP Review Tracking with Slack Notifications

Develop an AppStore review tracking bot that sends real-time Slack alerts, solving manual monitoring delays and boosting your app's responsiveness to user feedback effectively.

AppStore Reviews Bot|Automate APP Review Tracking with Slack Notifications

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


AppStore APP’s Reviews Slack Bot Stories

Using Ruby + Fastlane-SpaceShip to Build an App Review Tracking Notification Slack Bot

Photo by [Austin Distel](https://unsplash.com/@austindistel?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Austin Distel

Eating Rice Without Knowing Its Price

[AppReviewBot example](https://appreviewbot.com){:target="_blank"}

AppReviewBot as an example

I recently found out that the bot forwarding the latest app review messages in Slack requires a fee. I always thought this feature was free. The cost ranges from $5 to $200 per month because each platform offers more than just the “App Review Bot” function. They also provide data analytics, records, unified dashboards, competitor comparisons, and more. The price depends on the services each platform offers. The Review Bot is just one part of their service, but I only want this feature and nothing else. In that case, paying seems quite wasteful.

Question

Originally, we used the free open-source tool TradeMe/ReviewMe for Slack notifications, but this tool has been unmaintained for years. Occasionally, Slack floods with old reviews, which is alarming (many bugs have long been fixed, making us think there was a new issue!). The cause is unknown.

So consider finding other tools or methods as replacements.

TL;DR [2022/08/10] Update:

The App Reviews Bot has been redesigned using the new App Store Connect API and relaunched as “ZReviewTender — Free and Open Source App Reviews Monitoring Bot”.

====

2022/07/20 Update

App Store Connect API now supports reading and managing Customer Reviews. The App Store Connect API natively supports accessing app reviews, no longer requiring Fastlane — Spaceship to fetch reviews from the backend.

Principle Exploration

After having the motivation, next study the principles of achieving the goal.

Official API ❌

Apple provides the App Store Connect API, but it does not offer a feature to fetch reviews.

Public URL API (RSS) ⚠️

Apple provides a public APP review RSS subscription URL, and offers both RSS XML and JSON formats.

1
https://itunes.apple.com/國家碼/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json
  • Country codes: Refer to this document.

  • APP_ID: Go to the App’s web page, and you will get the URL: https://apps.apple.com/tw/app/APPNAME/id 12345678, the number after “id” is the App ID (numbers only).

  • page: Requests can be made for pages 1 to 10; beyond that, retrieval is not possible.

  • sortBy: mostRecent/json requests the latest data in JSON format. You can also change it to mostRecent/xml for XML format.

The evaluation data is returned as follows:

rss.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
  "author": {
    "uri": {
      "label": "https://itunes.apple.com/tw/reviews/id123456789"
    },
    "name": {
      "label": "test"
    },
    "label": ""
  },
  "im:version": {
    "label": "4.27.1"
  },
  "im:rating": {
    "label": "5"
  },
  "id": {
    "label": "123456789"
  },
  "title": {
    "label": "A Wonderful Existence!"
  },
  "content": {
    "label": "Life is worthwhile now~",
    "attributes": {
      "type": "text"
    }
  },
  "link": {
    "attributes": {
      "rel": "related",
      "href": "https://itunes.apple.com/tw/review?id=123456789&type=Purple%20Software"
    }
  },
  "im:voteSum": {
    "label": "0"
  },
  "im:contentType": {
    "attributes": {
      "term": "Application",
      "label": "Application"
    }
  },
  "im:voteCount": {
    "label": "0"
  }
}

Advantages:

  1. Public access without authentication steps required

  2. Simple and easy to use

Disadvantages:

  1. This RSS API is very outdated and has not been updated.

  2. The feedback information is too limited (no comment time, edited review?, replied?)

  3. Encountering data corruption issues (older data occasionally appears suddenly on later pages)

  4. Access up to 10 pages

The biggest issue we encountered is 3; however, it is unclear whether this is a problem with the Bot tool we used or with the RSS URL data.

Private URL API ✅

This method is somewhat unconventional and was a sudden idea I came up with; however, after reviewing other Review Bots, I found that many websites use this approach. It should be fine, and I saw tools doing this 4 to 5 years ago, though I didn’t study it in depth back then.

Advantages:

  1. Same as Apple backend data

  2. Complete and up-to-date data

  3. More detailed filtering options available

  4. Deeply integrated app tools also use this method (AppRadar/AppReviewBot…).

Disadvantages:

  1. Unofficial Methods (Unconventional Approaches)

  2. Because Apple enforces full two-step verification, login sessions need to be updated regularly.

Step 1 — Sniff the API that loads data for the App Store Connect backend review section:

To access Apple’s backend, use the command:

1
https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENT

This endpoint retrieves the list of reviews:

index = page offset, displaying up to 100 items at a time.

Evaluation data returned as follows:

private.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "value": {
    "id": 123456789,
    "rating": 5,
    "title": "A Wonderful Existence!",
    "review": "Life is worth it~",
    "created": null,
    "nickname": "test",
    "storeFront": "TW",
    "appVersionString": "4.27.1",
    "lastModified": 1618836654000,
    "helpfulViews": 0,
    "totalViews": 0,
    "edited": false,
    "developerResponse": null
  },
  "isEditable": true,
  "isRequired": false,
  "errorKeys": null
}

After testing, it was found that simply including cookie: myacinfo=<Token> can forge the request to obtain data:

The API is ready, and the required headers are known. Next, we need to find a way to automate retrieving this cookie information from the backend.

Step 2 — The Universal Fastlane

Because Apple now enforces full Two-Step Verification, automating login authentication has become more complicated. Fortunately, Fastlane, which competes with Apple’s security, implements official App Store Connect API, iTMSTransporter, and web authentication (including two-step verification). We can directly use Fastlane commands:

1
fastlane spaceauth -u <App Store Connect account (Email)>

This command completes web login verification (including two-factor authentication) and then saves the cookie into the FASTLANE_SESSION file.

You will get a string similar to the following:

1
2
3
4
5
6
7
8
9
10
11
12
!ruby/object:HTTP::Cookie
name: myacinfo  value: <token>  
domain: apple.com for_domain: true  path: "/"  
secure: true  httponly: true  expires: max_age: 
created_at: 2021-04-21 20:42:36.818821000 +08:00  
accessed_at: 2021-04-21 22:02:45.923016000 +08:00
!ruby/object:HTTP::Cookie
name: <hash>  value: <token>
domain: idmsa.apple.com for_domain: true  path: "/"
secure: true  httponly: true  expires: max_age: 2592000
created_at: 2021-04-19 23:21:05.851853000 +08:00
accessed_at: 2021-04-21 20:42:35.735921000 +08:00

Assign myacinfo = value to get the review list.

Step 3 — SpaceShip

I originally thought Fastlane could only help us up to this point, and after that, we had to manually connect the flow of getting cookies from Fastlane and then calling the API. However, after some exploration, I found that Fastlane’s authentication module SpaceShip has even more powerful features!

`SpaceShip`

SpaceShip

SpaceShip already provides a method to fetch the review list for us: Class: Spaceship::TunesClient::get_reviews!

1
2
app = Spaceship::Tunes::login(appstore_account, appstore_password)
reviews = app.get_reviews(app_id, platform, storefront, versionId = '')

*storefront = region

Step 4 — Assembly

Fastlane and Spaceship are both written in Ruby, so we also need to use Ruby to create this Bot tool.

We can create a reviewBot.rb file, and to run it, simply enter the following in the Terminal:

1
ruby reviewBot.rb

Done. ( *For more Ruby environment issues, see the tips at the end)

First, since the original get_reviews API parameters do not meet our needs; what I want is review data for all regions and all versions, without filtering, and with pagination support:

extension.rb:

1
2
3
4
5
6
7
8
9
# Extension Spaceship->TunesClient
module Spaceship
  class TunesClient < Spaceship::Client
    def get_recent_reviews(app_id, platform, index)
      r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT")
      parse_response(r, 'data')['reviews']
     end
  end
end

So we extend a method in TunesClient ourselves, with parameters only including app_id, platform = ios (all lowercase), and index = pagination offset.

Next, assemble the login authentication and fetch the review list:

get_recent_reviews.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
index = 0
breakWhile = true
while breakWhile
  app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password)
  reviews = app.get_recent_reviews($app_id, $platform, index)
  if reviews.length() <= 0
    breakWhile = false
    break
  end
  reviews.each { \|review\|
    index += 1
    puts review["value"]
  }
end

Use while loop to iterate through all pages and stop when no content is found.

Next, add a record of the last latest timestamp, and only notify the newest messages that have not been notified:

lastModified.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
lastModified = 0
if File.exists?(".lastModified")
  lastModifiedFile = File.open(".lastModified")
  lastModified = lastModifiedFile.read.to_i
end
newLastModified = lastModified
isFirst = true
messages = []

index = 0
breakWhile = true
while breakWhile
  app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password)
  reviews = app.get_recent_reviews($app_id, $platform, index)
  if reviews.length() <= 0
    breakWhile = false
    break
  end
  reviews.each { \|review\|
    index += 1
    if isFirst
      isFirst = false
      newLastModified = review["value"]["lastModified"]
    end

    if review["value"]["lastModified"] > lastModified && lastModified != 0  
      # Do not send notification on first use
      messages.append(review["value"])
    else
      breakWhile = false
      break
    end
  }
end

messages.sort! { \|a, b\|  a["lastModified"] <=> b["lastModified"] }
messages.each { \|message\|
    notify_slack(message)
}

File.write(".lastModified", newLastModified, mode: "w+")

Simply use a .lastModified to record the time obtained during the last execution.

No notification on first use, otherwise it will spam all at once

Final step, compose the push notification & send it to Slack:

slack.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Slack Bot
def notify_slack(review)
  rating = review["rating"].to_i
  color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger")
  like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : ""
  date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}"
  
    
  isResponse = ""
  if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"]
    isResponse = " (Reply is outdated)"
  end
  
  edited = review["edited"] == false ? "" : ":memo: User updated review#{isResponse}:"

  stars = "★" * rating + "☆" * (5 - rating)
  attachments = {
    :pretext => edited,
    :color => color,
    :fallback => "#{review["title"]} - #{stars}#{like}",
    :title => "#{review["title"]} - #{stars}#{like}",
    :text => review["review"],
    :author_name => review["nickname"],
    :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses\|Go To App Store>"
  }
  payload = {
   :attachments => [attachments],
   :icon_emoji => ":storm_trooper:",
   :username => "ZhgChgLi iOS Review Bot"
  }.to_json
  cmd = "curl -X POST --data-urlencode 'payload=#{payload}' SLACK_WEB_HOOK_URL"
  system(cmd, :err => File::NULL)
  puts "#{review["id"]} send Notify Success!"
 end

SLACK_WEB_HOOK_URL = Incoming WebHook URL

Final Result

appreviewbot.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
require "Spaceship"
require 'json'
require 'date'

# Config
$slack_web_hook = "Target notification web hook url"
$slack_debug_web_hook = "Notification web hook url for bot errors"
$appstore_account = "APPStoreConnect account (Email)"
$appstore_password = "APPStoreConnect password"
$app_id = "APP_ID"
$platform = "ios"

# Extension Spaceship->TunesClient
module Spaceship
  class TunesClient < Spaceship::Client
    def get_recent_reviews(app_id, platform, index)
      r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT")
      parse_response(r, 'data')['reviews']
     end
  end
end

# Slack Bot
def notify_slack(review)
  rating = review["rating"].to_i
  color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger")
  like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : ""
  date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}"
  
    
  isResponse = ""
  if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"]
    isResponse = " (Customer service reply is outdated)"
  end
  
  edited = review["edited"] == false ? "" : ":memo: User updated the review#{isResponse}:"

  stars = "★" * rating + "☆" * (5 - rating)
  attachments = {
    :pretext => edited,
    :color => color,
    :fallback => "#{review["title"]} - #{stars}#{like}",
    :title => "#{review["title"]} - #{stars}#{like}",
    :text => review["review"],
    :author_name => review["nickname"],
    :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses\|Go To App Store>"
  }
  payload = {
   :attachments => [attachments],
   :icon_emoji => ":storm_trooper:",
   :username => "ZhgChgLi iOS Review Bot"
  }.to_json
  cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_web_hook}"
  system(cmd, :err => File::NULL)
  puts "#{review["id"]} send Notify Success!"
 end

begin
    lastModified = 0
    if File.exists?(".lastModified")
      lastModifiedFile = File.open(".lastModified")
      lastModified = lastModifiedFile.read.to_i
    end
    newLastModified = lastModified
    isFirst = true
    messages = []

    index = 0
    breakWhile = true
    while breakWhile
      app = Spaceship::Tunes::login($appstore_account, $appstore_password)
      reviews = app.get_recent_reviews($app_id, $platform, index)
      if reviews.length() <= 0
        breakWhile = false
        break
      end
      reviews.each { \|review\|
        index += 1
        if isFirst
          isFirst = false
          newLastModified = review["value"]["lastModified"]
        end

        if review["value"]["lastModified"] > lastModified && lastModified != 0  
          # Do not send notifications on first run
          messages.append(review["value"])
        else
          breakWhile = false
          break
        end
      }
    end
    
    messages.sort! { \|a, b\|  a["lastModified"] <=> b["lastModified"] }
    messages.each { \|message\|
        notify_slack(message)
    }
    
    File.write(".lastModified", newLastModified, mode: "w+")
rescue => error
    attachments = {
        :color => "danger",
        :title => "AppStoreReviewBot Error occurs!",
        :text => error,
        :footer => "*Due to Apple technical restrictions, the precise review scraping feature requires re-login about once a month. Please understand."
    }
    payload = {
        :attachments => [attachments],
        :icon_emoji => ":storm_trooper:",
        :username => "ZhgChgLi iOS Review Bot"
    }.to_json
    cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_debug_web_hook}"
    system(cmd, :err => File::NULL)
    puts error
end

Also added begin…rescue (try…catch) protection. If an error occurs, a Slack notification is sent for us to check (usually due to session expiration).

Finally, just add this script to crontab/schedule or other scheduling tools for regular execution!

Renderings:

Other Free Options

  1. AppFollow: Using the Public URL API (RSS) is just acceptable.

  2. feedis.io: Using the Private URL API requires providing your account credentials to them.

  3. TradeMe/ReviewMe: Self-hosted service (node.js), we originally used this but encountered the issues mentioned above.

  4. JonSnow: Self-hosted service (GO), supports one-click deployment to Heroku, author: @saiday

Warm Tips

1.⚠️Private URL API method requires re-verification every 30 days for accounts with two-factor authentication, and there is currently no solution; if you can create an account without two-factor authentication, you can use it smoothly without issues.

[#important-note-about-session-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"}

#important-note-about-session-duration

2.⚠️ Whether free, paid, or self-hosted as in this article; never use a developer account. Always create a separate App Store Connect account with only “Customer Support” access to prevent security issues.

  1. It is recommended to use rbenv to manage Ruby, as the system’s built-in version 2.6 may cause conflicts.

  2. On macOS Catalina, if you encounter GEM or Ruby environment errors, refer to this reply for a solution.

Problem Solved!

After the above journey, I have a better understanding of how Slack Bots work; how the iOS App Store crawls review content, and I also tried Ruby! It’s quite nice to write!

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.