Skip to content

Instantly share code, notes, and snippets.

@koshian
Last active October 22, 2023 18:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save koshian/a99c07ae93fecfe00a4e620c92c76cc1 to your computer and use it in GitHub Desktop.
Save koshian/a99c07ae93fecfe00a4e620c92c76cc1 to your computer and use it in GitHub Desktop.
Automate your Rocket League match data retrieval, efficiently fetching from Ballchasing.com via their API for further analysis through webhooks.
#!/usr/bin/env ruby
#
# This script takes data from Rocket League matches. It uses the API of Ballchasing.com,
# a service that can store replay data.
#
# It is written to run every minute in crontab.
#
# We use it to get win/loss data from private matches and to notify the results to Discord.
#
# Uploading replays to ballchasing.com can be done automatically by BakkesMod.
#
# Ballchasing.com allows free users to access the API twice per second, but we are not
# looking for that kind of speed. So this script will access every 3 seconds if there is data
# from a match within 20 minutes, otherwise it will not access anything for 3 hours.
# After 3 hours, it will access every 10 minutes.
#
#
# example of ~/.rlbcchecker.yaml
# ```yaml
# webhook_uri: http://example.com/webhook
# api_key: ballchasing_api_key
# player_names:
# - user1
# - user2
# ```
#
# webhook example in Google App Script:
# function doPost(e) {
# var params = JSON.parse(e.postData.contents);
# const output = ContentService.createTextOutput();
# output.setMimeType(ContentService.MimeType.JSON);
#
# const title = params.title
# const url = params.url
# const description = Utilities.base64Decode(params.description)
# Logger.log(Utilities.newBlob(description).getDataAsString())
# var rawdata = JSON.parse(Utilities.newBlob(description).getDataAsString())
# const blueGoals = "goals" in rawdata.blue ? rawdata.blue.goals : 0
# const orangeGoals = "goals" in rawdata.orange ? rawdata.orange.goals : 0
#
# const data = [
# rawdata.replay_title,
# blueGoals,
# rawdata.blue.players.map(p => p.name).join(", "),
# rawdata.blue.players.map(p => p.score).join(", "),
# orangeGoals,
# rawdata.orange.players.map(p => p.name).join(", "),
# rawdata.orange.players.map(p => p.score).join(", "),
# rawdata.id,
# rawdata.link,
# rawdata.rocket_league_id,
# rawdata.map_code,
# rawdata.map_name,
# rawdata.playlist_id,
# rawdata.playlist_name,
# rawdata.duration,
# rawdata.overtime,
# rawdata.season,
# rawdata.season_type,
# new Date(rawdata.date),
# rawdata.date_has_tz,
# rawdata.visibility,
# new Date(rawdata.created),
# JSON.stringify(rawdata)
# ]
#. SpreadsheetApp.openById('xxxxxxxxxxxxxxxxxxxx').getSheetByName('data').appendRow(data);
# return ContentService.createTextOutput('ok');
# }
#
# How to know if we have won our game:
# That data is not included and is determined by the text of the title.
# This is how we currently judge our own win/loss results from the title.
#
# const win = rawdata.replay_title.match(/Win$/)
#
# How to get MVP player:
# const mvp_player_name = function() {
# for(let i = 0; i < data.orange.players.length; i++ ) {
# if ("mvp" in data.orange.players[i] && data.orange.players[i].mvp)
# return data.orange.players[i].name
#. }
# for(let i = 0; i < data.blue.players.length; i++ ) {
# if ("mvp" in data.blue.players[i] && data.blue.players[i].mvp)
# return data.blue.players[i].name
# }
# return false
# }()
#
# License:
# This is free and unencumbered software released into the public domain.
#
# Anyone is free to copy, modify, publish, use, compile, sell, or
# distribute this software, either in source code form or as a compiled
# binary, for any purpose, commercial or non-commercial, and by any
# means.
# In jurisdictions that recognize copyright laws, the author or authors
# of this software dedicate any and all copyright interest in the
# software to the public domain. We make this dedication for the benefit
# of the public at large and to the detriment of our heirs and
# successors. We intend this dedication to be an overt act of
# relinquishment in perpetuity of all present and future rights to this
# software under copyright law.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# For more information, please refer to <http://unlicense.org/>
#
require 'net/http'
require 'uri'
require 'json'
require 'time'
require 'yaml'
require 'base64'
API_URL = 'https://ballchasing.com/api/replays'
API_PARAMS = [
['count', 200],
['uploader', 'me'],
]
MINUTE = 60
HOUR = 60 * MINUTE
STATE_FILE = File.expand_path('~/.rlbcchecker.yaml')
def update_state_file(state, new_last_update)
state['last_update'] = new_last_update
File.write(STATE_FILE, state.to_yaml)
end
def send_request(state, payload)
webhook = URI.parse(state['webhook_uri'])
http = Net::HTTP.new(webhook.host, webhook.port)
http.use_ssl = true if webhook.scheme == 'https'
request = Net::HTTP::Post.new(webhook.request_uri, {'Content-Type' => 'application/json'})
request.body = payload.to_json
http.request(request)
end
def fetch_data_from_api(state)
params = API_PARAMS
if state.has_key?("player_names")
player_param = state["player_names"].map do |name|
["player-name", name]
end
params = params.concat player_param
end
begin
uri = URI(API_URL)
uri.query = URI.encode_www_form(params)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true # Assume the API requires SSL
request = Net::HTTP::Get.new(uri.request_uri)
request['Authorization'] = state["api_key"]
response = http.request(request)
rescue EOFError => e
STDERR.puts "Network communication failed: #{e.message}"
nil # Return nil or handle error as appropriate
end
JSON.parse(response.body)
end
def should_run_task(state)
current_time = Time.now.utc
last_update = state['last_update'] ? Time.parse(state['last_update']) : nil
return true, true unless last_update
since_last_update = current_time - last_update
return true, true if since_last_update > 0 && since_last_update < 20 * MINUTE
return true, false if since_last_update >= 3 * HOUR && (since_last_update / 60).to_i % 10 == 0
return false, false
end
def run_task(state, data)
last_update = state.key?('last_update') ? Time.parse(state['last_update']) : nil
new_last_update = nil
data['list'].sort_by { |item| Time.parse(item['date']) }.each do |item|
item_date = Time.parse(item['date'])
# If a future datetime was entered (it really happened!) is replaced by created
if item_date > Time.now
item_date = Time.parse(item['created'])
item['date'] = item_date.iso8601
end
# Process data only if it's newer than the last processed date
next unless last_update.nil? || item_date > last_update
payload = {
title: item['replay_title'],
url: item['link'],
description: Base64.strict_encode64(item.to_json),
}
response = send_request(state, payload)
# GAS returns 302 even if accessed successfully, so 302 is assumed to be a success code
if response.code == '302'
new_last_update = item['date']
update_state_file(state, new_last_update) unless new_last_update.nil?
else
raise "API request failed with code #{response.code}: #{response.body}"
end
end
end
def load_state
unless File.exists?(STATE_FILE)
raise "State file not found at #{STATE_FILE}"
end
state = YAML.load_file(STATE_FILE)
unless state.key?('api_key')
raise "API key not found in state file"
end
state
end
if __FILE__ == $0
state = load_state
should_run, in_game = should_run_task(state)
n = in_game ? 10 : 1
if should_run
n.times do
data = fetch_data_from_api(state)
run_task(state, data) if data
sleep(3)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment