Steamでゲームをリリースしているインディーゲーム開発者共通の悩みとして、「自作ゲームに新しいレビューが投稿された際、自動で知らせてくれる標準機能がない」という点があります。
レビューはプレイヤーからの大切なフィードバックであり、バグ報告や誤解に基づく不評には一刻も早く対応したいところです。また、好意的なレビューは開発の大きなモチベーションにもなります。
しかし、そのために毎日Steamworksのダッシュボードを何度もリロードして確認しに行くのは手間がかかり、精神的にもあまり健康的ではありません。
「レビューがついたら即座に通知を受け取りたい」と考え、様々なサードパーティツールや通知サービスを調べました。
外部のメール通知サービス(Steam Review Alertなど)は手軽ですが、無料枠では「1作品のみ」しか登録できないケースがほとんどです。また、ZapierやMakeなどのiPaaSでAPIを定期監視させようとすると、無料のタスク枠をあっという間に使い切ってしまいます。
そうした中でたどり着いたのが、Codecks社が提供している無料のDiscord Bot「Steamy」でした。
Steamyの最大の強みは、完全無料で複数タイトルのApp IDを無制限に登録・監視できる点にあります。シリーズ作品や過去作を複数抱えている開発者にとって、これ以上の選択肢はありません。
「Steamyの通知を専用のDiscordサーバーに集約し、さらにそのDiscordの通知をメールに飛ばして受け取る」という形をとれば、普段Discordを常駐させていない時間帯であっても、メール側で新着レビューを確実にキャッチできるため、見落としがなくなりました。
ですが、10/22からSteamがユーザーレビューを一括取得するためのAPIエンドポイントを変更した為、残念ながら現時点ではSteamyでレビューを取得できなくなっているようです(今後アップデートで対応される可能性はあります)(追記:取得までの時間はかかるようになっていますが一応まだ取得はできていることを確認しました。その為、スクリプト設置が困難な方はSteamyを利用するのが一番手っ取り早いかと思います)。
仕方がないので、新しいWeb APIに対応したレビュー取得スクリプトを自前で書きました。Ruby 2.0 以降が動作し、cron などの定期実行が使える環境(VPS、Linux サーバー、常時稼働 PC など)があれば、このスクリプトを設置して定期実行させることで、Steamy のように新着レビューを Discord へ自動通知できるようになります。
特にメンテナンスなどするつもりはないので GitHub などには上げません。
自由に使用・改変・再配布していただいて問題ありませんが(MIT License 相当)、動作は保証いたしませんので利用は自己責任でお願いします。
動作に必要なSteam API KeyやDiscord Webhook URLの取得方法は後の項目で解説します。
以下のコードを steam_review_to_discord.rb というファイル名で保存して使用します。 初回実行時は、「既存レビューを記録するだけ」で、Discord には何も通知されません。
コピーした URL を、スクリプト内の DISCORD_WEBHOOK_URL へ設定してください。
※応援ボタンを押していただけると(ログイン不要)見てもらえる確率がちょっぴり上がります。応援よろしくお願いします
紅蜘蛛2/Red Spider2リマスター版(Win/Mac/Android/iOSで広告・課金一切なしの完全非営利無料配信)のリリースは来年1月末を目指しています。
興味を持った方は是非ウィッシュリストにご登録ください。
また、昨年リリースしたシリーズ1作目のリマスター版「紅蜘蛛/Red Spider」リマスター版や一昨年リリースした紅蜘蛛外伝:暗花もフルスチル化作品ですのでお気軽に遊んでみてください。いずれもWin/Mac/Android/iOSで完全非営利無料配信中です。
#!/usr/bin/env ruby
# Steam レビュー → Discord 通知スクリプト## 自分の Steam アプリ(本編・体験版・OST など複数 ID)に新しいレビューが付いたら、# Discord の Webhook に投稿する。cron などで定期実行して使う。## ■ ライセンス# 自由に使用・改変・再配布してよい(MIT License 相当)。# 動作は保証しない。利用は自己責任で。## ■ 動作環境# Ruby 2.0 以降(標準ライブラリのみ使用。gem のインストールは不要)# 動作確認環境: Ruby 2.0.0p648 (CentOS 6)## ■ 使い方# 1. APP_NAMES に監視したい App ID と表示名を書く# 2. DISCORD_WEBHOOK_URL / STEAM_API_KEY を設定する# (環境変数で渡す、または下の設定項目に直接書く)# 3. 手動で一度実行する: ruby steam_review_to_discord.rb# 初回実行は「既存レビューを記録するだけ」で、Discord には何も投稿しない# 4. cron に登録する(例: 毎時 0 分に実行)# 0 * * * * cd /path/to/script && ruby steam_review_to_discord.rb >> steam_review.log 2>&1## ■ 注意# - STEAM_API_KEY はパブリッシャーキーの場合、漏れると影響が大きい。# ファイルに直接書いた場合は、このファイルを他人に渡す・公開する前に必ず空にすること。# chmod 600 で他ユーザーから読めないようにしておくのが望ましい。# - Webhook URL も漏れると第三者がチャンネルに投稿できてしまう。同様に扱うこと。# - 状態ファイル(last_seen.json)を消すと、次回は「初回」扱いになる(通知は洪水にならない)。# - 取得頻度は 1 時間おき程度にとどめ、Steam / Discord のレート制限に配慮すること。
require 'net/http'require 'uri'require 'json'
# ================= 設定項目 =================
# Discord Webhook URLDISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_WEBHOOK_URL_HERE'
# Steam Web API Key STEAM_API_KEY = 'YOUR_STEAM_WEB_API_KEY_HERE'
# 監視対象の App ID と表示名のリスト (App ID => 表示名)APP_NAMES = { 'App ID1' => 'タイトル1', 'App ID2' => 'タイトル2'}.freeze
# 状態管理ファイル (スクリプトと同じディレクトリに保存)STATE_FILE = File.join(__dir__, 'last_seen.json')FAIL_FILE = File.join(__dir__, 'fail_count.json')
REQUEST_INTERVAL = 1.5 # アプリごとの API 呼び出し間隔(秒)SEND_INTERVAL = 1.0 # Discord への連続投稿の間隔(秒)REVIEWS_PER_PAGE = 100 # 1 回の取得件数REVIEW_FILTER = 1 # 1 = 投稿日時順(最新)KEEP_IDS = 200 # アプリごとに保持する通知済みレビュー ID の数FAILURE_ALERT_THRESHOLD = 3 # この回数連続で取得に失敗したら Discord に警告を 1 回出すMAX_TEXT = 800 # Discord に載せるレビュー本文の最大文字数# ============================================
def log(message) puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{message}"end
def build_http(uri) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = (uri.scheme == 'https') http.open_timeout = 10 http.read_timeout = 10 httpend
# ---------- 状態ファイル ----------
def load_json(path) return {} unless File.exist?(path)
data = JSON.parse(File.read(path)) data.is_a?(Hash) ? data : {}rescue StandardError {}end
# 書き込み途中で落ちても壊れないよう、一時ファイルに書いてから置き換えるdef save_json(path, data) tmp = "#{path}.tmp" File.write(tmp, JSON.pretty_generate(data)) File.rename(tmp, path)end
# ---------- Steam ----------
# 成功時: レビューの配列(古い順)。失敗時: nildef fetch_reviews(app_id) input = { appid: app_id.to_i, languages: ['all'], purchase_type: 1, # 全購入区分 filter: REVIEW_FILTER, num_per_page: REVIEWS_PER_PAGE, cursor: '*' } api_key = STEAM_API_KEY.to_s.strip unless api_key.empty? || api_key.include?('YOUR_STEAM_WEB_API_KEY') input[:key] = api_key end
uri = URI('https://api.steampowered.com/IUserReviewsService/GetAppReviews/v1/') uri.query = URI.encode_www_form(input_json: input.to_json)
res = build_http(uri).request(Net::HTTP::Get.new(uri)) unless res.is_a?(Net::HTTPSuccess) log "[#{app_id}] Steam API エラー: HTTP #{res.code}" return nil end
response = JSON.parse(res.body)['response'] unless response.is_a?(Hash) log "[#{app_id}] Steam API の応答形式が想定と違います" return nil end
(response['reviews'] || []).sort_by { |r| r['timestamp_created'].to_i }rescue StandardError => e log "[#{app_id}] Steam API 取得例外: #{e.message}" nilend
# ---------- Discord ----------
def post_discord(payload, label) uri = URI(DISCORD_WEBHOOK_URL)
3.times do req = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json') req.body = payload.to_json res = build_http(uri).request(req)
if res.is_a?(Net::HTTPSuccess) return true elsif res.code == '429' wait = (res['Retry-After'] || 2).to_f log "[#{label}] Discord レート制限。#{wait} 秒待機" sleep wait else log "[#{label}] Discord 送信エラー: HTTP #{res.code}" return false end end falserescue StandardError => e log "[#{label}] Discord 送信例外: #{e.message}" falseend
def build_embed(app_id, review) voted_up = review['voted_up'] author = review['author'] || {} hours = (author['playtime_forever'].to_i / 60.0).round(1)
# 日本時間 (JST) に変換してフォーマット created_at = if review['timestamp_created'] Time.at(review['timestamp_created'].to_i).getlocal('+09:00').strftime('%Y/%m/%d %H:%M') else '不明' end
text = review['review'].to_s.strip text = "#{text[0, MAX_TEXT]}..." if text.length > MAX_TEXT text = '(本文なし)' if text.empty?
{ title: "新着レビュー: #{APP_NAMES.fetch(app_id, app_id)} (#{voted_up ? '👍 高評価' : '👎 低評価'})", url: "https://store.steampowered.com/app/#{app_id}/", color: voted_up ? 0x5C7E10 : 0xD9534F, fields: [ { name: '投稿日時', value: created_at, inline: true }, { name: 'プレイ時間', value: "#{hours} 時間", inline: true }, { name: '購入区分', value: review['steam_purchase'] ? 'Steamで購入' : 'キー有効化等', inline: true }, { name: 'レビュー内容', value: text, inline: false } ], footer: { text: "Review ID: #{review['recommendationid']}" } }end
# ---------- メイン処理 ----------
def process_app(app_id, state, fails) reviews = fetch_reviews(app_id)
# 取得失敗: 連続回数を数え、しきい値に達した回だけ警告を出す if reviews.nil? fails[app_id] = fails[app_id].to_i + 1 if fails[app_id] == FAILURE_ALERT_THRESHOLD name = APP_NAMES.fetch(app_id, app_id) post_discord({ content: "⚠️ #{name} (#{app_id}) のレビュー取得が #{FAILURE_ALERT_THRESHOLD} 回連続で失敗しています。" }, app_id) end return end fails.delete(app_id)
# 初回: 既存レビューを記録するだけで通知しない unless state.key?(app_id) state[app_id] = reviews.map { |r| r['recommendationid'] }.last(KEEP_IDS) log "[#{app_id}] 初回実行: 既存レビュー #{reviews.size} 件を記録しました(通知なし)" return end
seen = state[app_id] new_reviews = reviews.reject { |r| seen.include?(r['recommendationid']) } if new_reviews.empty? log "[#{app_id}] 新着レビューはありません" return end
log "[#{app_id}] 新着レビュー #{new_reviews.size} 件" # 古い順に送る。途中で失敗したら残りは次回に回す new_reviews.each do |review| break unless post_discord({ embeds: [build_embed(app_id, review)] }, app_id)
seen << review['recommendationid'] sleep SEND_INTERVAL end state[app_id] = seen.last(KEEP_IDS)end
def main if DISCORD_WEBHOOK_URL.include?('YOUR_WEBHOOK_URL_HERE') || DISCORD_WEBHOOK_URL.strip.empty? abort 'DISCORD_WEBHOOK_URL が未設定です。環境変数かスクリプト内の設定項目に指定してください。' end
log '=== レビュー確認を開始します ===' state = load_json(STATE_FILE) fails = load_json(FAIL_FILE)
APP_NAMES.each_key do |app_id| process_app(app_id, state, fails) save_json(STATE_FILE, state) save_json(FAIL_FILE, fails) sleep REQUEST_INTERVAL end
log '=== 完了しました ==='end
main if __FILE__ == $PROGRAM_NAMEフォローすると、開発ログや新作ゲームの公開が通知されます