ZhgChg.Li

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

AppStore APP’s Reviews Slack Bot Matters

Independent writing, free to read — please support these ads

 

Advertise here →

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

Photo by Austin Distel

Photo by Austin Distel

Eating Rice Without Knowing the Price of Rice

AppReviewBot example

AppReviewBot example

I recently found out that the bot forwarding the latest app reviews to Slack requires a paid subscription. I always thought this feature was free. The cost ranges from $5 to $200 per month because these platforms don’t offer just the “App Review Bot” feature. They also provide data analytics, record keeping, unified dashboards, competitor comparisons, and more. The pricing depends on the services each platform offers. The Review Bot is only a part of their package, but I only want this feature and nothing else. Paying for the full service feels like a waste in this case.

Problem

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

So consider finding other tools or methods to replace it.

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

The App Reviews Bot has now been redesigned using the brand-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 native App Store Connect API already allows access to app reviews, so there is no longer a need to use Fastlane — Spaceship to fetch reviews from the backend.

Principle Exploration

Independent writing, free to read — please support these ads

 

Advertise here →

With the motivation set, let’s next explore the principles behind 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, offering both rss xml and json formats.

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 a URL like: https://apps.apple.com/tw/app/APP_NAME/id 12345678. The number after “id” is the App ID (digits only).

  • page: You can request pages 1 to 10; data beyond that cannot be retrieved.

  • sortBy: mostRecent/json fetches the latest reviews in JSON format. You can also use mostRecent/xml for XML format.

The review data is returned as follows:

rss.json:

{
  "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~",
    "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. Publicly accessible without authentication steps

  2. Simple and Easy to Use

Disadvantages:

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

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

  3. Encountering Data Disorder Issues (Occasionally Older Data Appears Suddenly on Later Pages)

  4. Access up to 10 pages at most

The biggest issue we encountered was 3; however, we are not sure if this is a problem with the Bot tool we used or with the RSS URL data.

Private URL API ✅

This method is somewhat unorthodox and was a sudden idea I came up with; however, after looking into other Review Bots, I found many websites use the same 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. APP tools with deep integration also use this method (AppRadar/AppReviewBot…).

Disadvantages:

  1. Unofficial Method (Workaround)

  2. Since Apple enforces full two-step authentication, the login session must be refreshed regularly.

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

Get Apple’s backend by sending:

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 = Pagination offset, showing up to 100 items at a time.

The review data is returned as follows:

private.json:

{
  "value": {
    "id": 123456789,
    "rating": 5,
    "title": "Great existence!",
    "review": "Life is worthwhile~",
    "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 spoof the request to obtain data:

With the API ready and the required headers known, the next step is to find a way to automate retrieving the backend cookie information.

Step 2 — The Versatile Fastlane

Since Apple now enforces full Two-Step Verification, automating login authentication has become more complicated. Fortunately, Fastlane, which has been battling Apple’s security measures, supports official App Store Connect API, iTMSTransporter, and web authentication (including two-step verification). We can directly use Fastlane’s command:

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

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

You will get a string like this:

!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
!ruby/object:HTTP::Cookie  
name: dqsid  value: <token>
domain: appstoreconnect.apple.com  for_domain: false  path: "/"  secure: true  httponly: true  expires: max_age: 1800
created_at: &1 2021-04-21 22:02:47.118437000 +08:00
accessed_at: *1

Assigning myacinfo = value allows you to retrieve the review list.

Step 3 — SpaceShip

I originally thought Fastlane could only help us this far, and that we would have to manually connect the flow from getting the cookie with Fastlane to calling the API. But after some exploration, I found that Fastlane’s authentication module SpaceShip has even more powerful features!

`SpaceShip`

SpaceShip

SpaceShip already provides a packaged method for fetching the review list: Class: Spaceship::TunesClient::get_reviews!

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 will use Ruby to create this Bot tool.

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

ruby reviewBot.rb

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

First, since the original get_reviews method parameters do not meet our needs; I want review data from all regions and all versions, without filtering, and supporting pagination:

extension.rb:

# 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 = page offset.

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

get_recent_reviews.rb:

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 a while loop to iterate through all pages and stop when no content is found.

Next, add a record of the last latest timestamp to notify only new messages that haven’t been notified before:

lastModified.rb:

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.

Do not send notifications on the first run to avoid spamming all at once

Final step: Compose the notification message & send it to Slack:

slack.rb:

# 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:

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 = " (Developer response 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}"
  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 notify 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+")
rescue => error
    attachments = {
        :color => "danger",
        :title => "AppStoreReviewBot Error occurs!",
        :text => error,
        :footer => "*Due to Apple technical limitations, precise review scraping requires re-login setup about once a month. Thank you for your understanding."
    }
    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

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

Finally, just add this script to crontab / schedule or other scheduling tools to run it regularly!

Screenshot:

Other Free Options

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

  2. feedis.io: Uses the Private URL API and requires your account credentials.

  3. TradeMe/ReviewMe: A self-hosted service (node.js). We originally used this but faced the issues mentioned above.

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

Friendly Reminder

1.⚠️ For the Private URL API method, if the account has two-factor authentication enabled, it requires re-verification every 30 days at most to keep using it, and there is currently no workaround; if you can create an account without two-factor authentication, you can use it smoothly without issues.

#important-note-about-session-duration

#important-note-about-session-duration

2.⚠️ Whether free, paid, or self-hosted as described in this article; never use a developer account. Always create a separate App Store Connect account with only “Customer Support” permissions 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. If you encounter GEM or Ruby environment errors on macOS Catalina, you can refer to this reply for a solution.

Problem Solved!

Independent writing, free to read — please support these ads

 

Advertise here →

After going through the above process, I have a better understanding of how the Slack Bot works and how to scrape iOS App Store reviews. I also got a feel for Ruby—it’s quite nice to write!

Improve this page
Edit on GitHub
Also published on Medium
Read the original
Share this essay
Copy link · share to socials
ZhgChgLi
Author

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

Comments