Post

AppStore APP’s Reviews Bot 那些事

動手打造 APP 評價追蹤通知 Slack 機器人

AppStore APP’s Reviews Bot 那些事

ℹ️ℹ️ℹ️ Click here to view the English version of this article, translated by OpenAI.


AppStore APP’s Reviews Slack Bot 那些事

使用 Ruby+Fastlane-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人

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

Photo by Austin Distel

吃米不知米價

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

AppReviewBot 為例

最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 $5 到 $200 美金/月都有,因為各平台都不會只做「App Review Bot」的功能,其他還有數據統計、紀錄、統一後台、與競品比較…等等,費用也是照各平台能提供的服務為標準;Review Bot 只是他們的一環,但我就只想用這個功能其他不需要,如果是這樣付費蠻浪費的。

問題

本來是用免費開源的工具 TradeMe/ReviewMe 來做 Slack 通知,但這個工具已年久失修,時不時 Slack 會爆噴一些舊的評價,看得讓人心驚膽顫(很多 Bug 都早已修復,害我們以為又有問題!),原因不明。

所以考慮找其他工具、方法取代。

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

現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」。

====

2022/07/20 Update

App Store Connect API 現已支援 讀取和管理 Customer Reviews ,App Store Connect API 原生已支援存取 App 評價, 不需要再使用 Fastlane — Spaceship 去後台拿評價。

原理探究

有了動機之後,再來研究下達成目標的原理。

官方 API ❌

蘋果有提供 App Store Connect API ,但沒提供撈取評價功能。

[2022/07/20 更新]: App Store Connect API 現已支援 讀取和管理 Customer Reviews

Public URL API (RSS) ⚠️

蘋果有提供公開的 APP 評價 RSS 訂閱網址 ,而且除了 rss xml 還提供 json 格式。

1
https://itunes.apple.com/國家碼/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json
  • 國家碼:可參考 這份文件
  • APP_ID:前往 App 網頁版,會得到網址:https://apps.apple.com/tw/app/APP名稱/id 12345678 ,id 後面的數字及為 App ID(純數字)。
  • page:可請求 1~10 頁,超過無法取得。
  • sortBy: mostRecent/json 請求最新的& json 格式,也可改為 mostRecent/xml 則為 xml 格式。

評價資料回傳如下:

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": "很棒的存在!"
  },
  "content": {
    "label": "人生值得了~",
    "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": "應用程式"
    }
  },
  "im:voteCount": {
    "label": "0"
  }
}

優點:

  1. 公開、不需身份驗證步驟即可存取
  2. 簡單好用

缺點:

  1. 此 RSS API 很老舊都沒更新
  2. 回傳評價的資訊太少(沒留言時間、已編輯過評價?、已回覆?)
  3. 遇到資料錯亂問題(後面幾頁偶爾會突然噴舊資料)
  4. 最多存取 10 頁

關於我們遇到的最大問題是 3;但這部分不確定是我們用的 Bot 工具 問題,還是這個 RSS URL 資料有問題。

Private URL API ✅

這個方法說來有點旁門左道,也是我突發奇想發現的;但在後續參考了其他 Review Bot 做法之後發現很多網站也都是這樣用,應該沒什麼問題而且我 4~5 年前就看過有工具這樣做了,只是當時沒深入研究。

優點:

  1. 同蘋果後台資料
  2. 資料完整且最新
  3. 可做更多細節篩選
  4. 具備深度整合的 APP 工具也是用這個方法(AppRadar/AppReviewBot…)

缺點:

  1. 非官方公布方法(旁門左道)
  2. 因蘋果實行全面兩步驟登入,所以登入 session 需要定期更新。

第一步 — 嗅探 App Store Connect 後台評論區塊 Load 資料的 API:

得到蘋果後台是透過打:

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

這個 endpoint 取得評價列表:

index = 分頁 offset,一次最多顯示 100 筆。

評價資料回傳如下:

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": "很棒的存在!",
    "review": "人生值得了~",
    "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
}

另外經過測試後發現,只需要在帶上 cookie: myacinfo=<Token> 即可偽造請求得到資料:

API 有了、要求的 header 知道了,再來就要想辦法自動化取得後台這個 cookie 資訊。

第二步 —萬能 Fastlane

因蘋果現在實行全 Two-Step Verification,所以對於登入驗證自動化變得更加煩瑣,幸好與蘋果鬥智鬥勇的 Fastlane ,除了正規的 App Store Connect API、iTMSTransporter、網頁認證(包含兩步驟認證)全都有實作;我們可以直接使用 Fastlane 的指令:

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

此指令會完成網頁登入驗證(包含兩步驟認證),然後將 cookie 存入 FASTLANE_SESSION 檔案之中。

會得到類似如下字串:

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

myacinfo = value 帶入就能取得評價列表。

第三步 — SpaceShip

本來以為 Fastlane 只能幫我們到這了,再來要自己串起從 Fastlane 拿到 cookie 然後打 api 的 flow;沒想到經過一番探索發現 Fastlane 關於驗證這塊的模組 SpaceShip 還有更多強大的功能!

`SpaceShip`

SpaceShip

SpaceShip 裡面已經幫我們打包好撈評價列表的方法 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 = 地區

第四步 — 組裝

Fastlane、Spaceship 都是由 ruby 撰寫,所以我們也要用 ruby 來製作這個 Bot 小工具。

我們可以建立一個 reviewBot.rb 檔案,編譯執行時只需在 Terminal 輸入:

1
ruby reviewBot.rb

即可。 ( *更多 ruby 環境問題可參考文末提示)

首先 ,因原本的 get_reviews 口的參數不符合我們需求;我想要的是全地區、全版本的評價資料、不需要篩選、支援分頁:

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

所以我們自己在 TunesClient 中擴充一個方法,裡面參數只帶 app_id、platform = ios ( 全小寫 )、index = 分頁 offset。

再來組裝登入驗證、撈評價列表:

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 帳號(Email), APPStoreConnect 密碼)
  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

使用 while 遍歷所有分頁,當跑到無內容時終止。

再來要加上紀錄上次最新一筆的時間,只通知沒通知過的最新訊息:

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 帳號(Email), APPStoreConnect 密碼)
  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  
      # 第一次使用不發通知
      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+")

單純用一個 .lastModified 紀錄上一次執行時拿到的時間。

*第一次使用不發通知,否則會一次狂噴

最後一步,組合推播訊息 & 發到 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 = " (回覆已過時)"
  end
  
  edited = review["edited"] == false ? "" : ":memo: 使用者更新評論#{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

最終結果

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 = "目標通知的 web hook url"
$slack_debug_web_hook = "機器人有錯誤時的通知 web hook url"
$appstore_account = "APPStoreConnect 帳號(Email)"
$appstore_password = "APPStoreConnect 密碼"
$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 = " (客服回覆已過時)"
  end
  
  edited = review["edited"] == false ? "" : ":memo: 使用者更新評論#{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  
          # 第一次使用不發通知
          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 => "*因蘋果技術限制,精準評價爬取功能約每一個月需要重新登入設定,敬請見諒。"
    }
    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

另外還加上了 begin…rescue (try…catch) 保護,如果有出現錯誤則發 Slack 通知我們回來檢查(多半是 session 過期)。

最後只要將此腳本加到 crontab / schedule 等排程工具定時執行即可!

效果圖:

免費的其他選擇

  1. AppFollow :使用 Public URL API (RSS),只能說堪用吧。
  2. feedis.io :使用 Private URL API,需要把帳號密碼給他們。
  3. TradeMe/ReviewMe :自架服務(node.js),我們原先用這個,但遇到前述問題。
  4. JonSnow :自架服務(GO),支援一鍵部署到 heroku,作者: @saiday

溫馨提示

1.⚠️Private URL API 方法,如果用有二階段驗證的帳號,最長每 30 天都需要重新驗證才能使用且目前無解;如果有辦法生出沒二階段的帳號就可以無痛爽爽用。

[#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.⚠️不論是免費、付費、本文的自架;切勿使用開發者帳號,務必開一個獨立的 App Store Connect 帳號使用,權限只開放「Customer Support」;防止資安問題。

3.Ruby 建議使用 rbenv 進行管理,因系統自帶 2.6 版容易造成衝突。

4.在 macOS Catalina 如遇到 GEM、Ruby 環境錯誤問題,可參考 此回覆 解決。

Problem Solved!

經過以上心路歷程,更瞭解的 Slack Bot 的運作方式;還有 iOS App Store 是如何爬取評價內容的,另外也摸了下 ruby!寫起來真不錯!

有任何問題及指教歡迎 與我聯絡


本文首次發表於 Medium ➡️ 前往查看

ZMediumToMarkdownMedium-to-jekyll-starter 提供自動轉換與同步技術。

Improve this page on Github.

Buy me a beer

985 Total Views
Last Statistics Date: 2025-03-25 | 897 Views on Medium.
This post is licensed under CC BY 4.0 by the author.