AppStore APP’s Reviews Slack Bot 那些事
使用 Ruby+Fastlane-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人
Photo by Austin Distel
吃米不知米價
最近才知道 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"
}
}
優點:
- 公開、不需身份驗證步驟即可存取
- 簡單好用
缺點:
- 此 RSS API 很老舊都沒更新
- 回傳評價的資訊太少(沒留言時間、已編輯過評價?、已回覆?)
- 遇到資料錯亂問題(後面幾頁偶爾會突然噴舊資料)
- 最多存取 10 頁
關於我們遇到的最大問題是 3;但這部分不確定是我們用的 Bot 工具 問題,還是這個 RSS URL 資料有問題。
Private URL API ✅
這個方法說來有點旁門左道,也是我突發奇想發現的;但在後續參考了其他 Review Bot 做法之後發現很多網站也都是這樣用,應該沒什麼問題而且我 4~5 年前就看過有工具這樣做了,只是當時沒深入研究。
優點:
- 同蘋果後台資料
- 資料完整且最新
- 可做更多細節篩選
- 具備深度整合的 APP 工具也是用這個方法(AppRadar/AppReviewBot…)
缺點:
- 非官方公布方法(旁門左道)
- 因蘋果實行全面兩步驟登入,所以登入 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 裡面已經幫我們打包好撈評價列表的方法 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 等排程工具定時執行即可!
效果圖:
免費的其他選擇
- AppFollow :使用 Public URL API (RSS),只能說堪用吧。
- feedis.io :使用 Private URL API,需要把帳號密碼給他們。
- TradeMe/ReviewMe :自架服務(node.js),我們原先用這個,但遇到前述問題。
- JonSnow :自架服務(GO),支援一鍵部署到 heroku,作者: @saiday
溫馨提示
1.⚠️Private URL API 方法,如果用有二階段驗證的帳號,最長每 30 天都需要重新驗證才能使用且目前無解;如果有辦法生出沒二階段的帳號就可以無痛爽爽用。
#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!寫起來真不錯!
有任何問題及指教歡迎 與我聯絡 。
===
View the English version of this article here.
本文首次發表於 Medium ➡️ 前往查看