├── .gitignore ├── LICENSE ├── README.md ├── checktop50.py ├── config.example.json ├── dontsteal.py ├── download.py ├── mods.py ├── osuapi.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff: 5 | .idea/misc.xml 6 | .idea/modules.xml 7 | .idea/vcs.xml 8 | .idea/workspace.xml 9 | .idea/dontsteal.iml 10 | analysis_log.txt 11 | __pycache__/ 12 | 13 | # Sensitive or high-churn files: 14 | .idea/dataSources/ 15 | .idea/dataSources.ids 16 | .idea/dataSources.xml 17 | .idea/dataSources.local.xml 18 | .idea/sqlDataSources.xml 19 | .idea/dynamic.xml 20 | .idea/uiDesigner.xml 21 | 22 | # Gradle: 23 | .idea/gradle.xml 24 | .idea/libraries 25 | 26 | # Mongo Explorer plugin: 27 | .idea/mongoSettings.xml 28 | 29 | ## File-based project format: 30 | *.iws 31 | 32 | ## Plugin-specific files: 33 | 34 | # IntelliJ 35 | /out/ 36 | 37 | # mpeltonen/sbt-idea plugin 38 | .idea_modules/ 39 | 40 | # JIRA plugin 41 | atlassian-ide-plugin.xml 42 | 43 | # Crashlytics plugin (for Android Studio and IntelliJ) 44 | com_crashlytics_export_strings.xml 45 | crashlytics.properties 46 | crashlytics-build.properties 47 | fabric.properties 48 | 49 | # Config 50 | config.json 51 | 52 | # Replay files 53 | *.osr -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Shaural 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dontsteal 2 | _Stop stealing others' replays and git gud scrub!_ 3 | 4 | # Setup 5 | * Run `python -m pip install -r requirements.txt` 6 | * Create a config.json file 7 | * Input your username, password, and [osu! API Key](https://osu.ppy.sh/p/api) 8 | * Or just the API key 9 | 10 | # NOTE 11 | * Your username and password are required to connect and download replays faster instead of using osu!API 12 | * If you still prefer using only the API write "-a" argument after the replay name (ex: `python checktop50.py replay.osr -a`) 13 | * Remember that this method it's quite slow (around 6 minutes) due to API rate limiting 14 | 15 | # Usage 16 | * You can run `python dontsteal.py replay.osr replay2.osr` to compare two replays you have (change `replay` and `replay2` with the name of your replays) 17 | * You can run `python checktop50.py replay.osr` to compare a replay with its beatmap top 50 in osu! (change `replay` with the name of your replay) 18 | 19 | # Contributors 20 | * [goeo_](https://github.com/goeo-) - Helped out with the initial logic. 21 | * [Swan](https://github.com/Swan) - Added the possibility to use osu! accounts to download replays rather than the slow API. 22 | 23 | # LICENSE 24 | MIT License -------------------------------------------------------------------------------- /checktop50.py: -------------------------------------------------------------------------------- 1 | """Compares a replay against the top 50 of its beatmap""" 2 | import sys 3 | import os 4 | import shutil 5 | import dontsteal 6 | import download 7 | import osuapi 8 | from glob import iglob 9 | from osrparse.replay import parse_replay_file 10 | 11 | #delete log file on every run 12 | if(os.path.exists("analysis_log.txt")): 13 | os.remove("analysis_log.txt") 14 | 15 | #open replay & analyze 16 | TARGET_REPLAY = parse_replay_file(sys.argv[1]) 17 | dontsteal.analyze(TARGET_REPLAY) 18 | #get replay events 19 | TARGET_REPLAY_EVENTS = dontsteal.get_events_per_second(TARGET_REPLAY) 20 | #empty list to be filled with replays 21 | TOP_50_REPLAYS = [] 22 | #empty string for logging 23 | OUTPUT = "" 24 | 25 | 26 | def log_print(text): 27 | """Print it cool for logging""" 28 | global OUTPUT 29 | OUTPUT += "%s\n" % text 30 | return 31 | 32 | 33 | #comparing replays using osu!API data 34 | if(len(sys.argv) == 3 and sys.argv[2] == "-a"): 35 | #download top 50 replays using data from osu!API 36 | osuapi.get_beatmap_info(TARGET_REPLAY.beatmap_hash) 37 | TOP_50_REPLAYS = osuapi.get_replays(osuapi.get_beatmap(TARGET_REPLAY.beatmap_hash)) 38 | 39 | if not TOP_50_REPLAYS: 40 | sys.exit("Beatmap not ranked, can't download replays!") 41 | 42 | print("""\n 43 | 44 | ---- Analysis Results ---- 45 | """) 46 | SUSPICIOUS = False 47 | for rp_api in TOP_50_REPLAYS: 48 | rp_events = dontsteal.get_events_per_second_api(rp_api[0], rp_api[1]) 49 | comparison = dontsteal.compare_data(TARGET_REPLAY_EVENTS, rp_events) 50 | 51 | log_print("Comparing to {}'s replay".format(rp_api[2])) 52 | 53 | # closeness 54 | log_print("Lowest values:") 55 | suspicious_low_values = True 56 | for values in sorted(comparison[0])[1:11]: 57 | if values >= 1: 58 | suspicious_low_values = False 59 | log_print(values) 60 | 61 | if suspicious_low_values and rp_api[2] != TARGET_REPLAY.player_name: 62 | SUSPICIOUS = True 63 | print("\nSuspicious lowest values with {top_player}'s replay".format(top_player=rp_api[2])) 64 | 65 | log_print("\nAverage of similarity:") 66 | average_value = sum(comparison[0]) / len(comparison[0]) 67 | log_print(average_value) 68 | 69 | if average_value <= 15 and rp_api[2] != TARGET_REPLAY.player_name: 70 | SUSPICIOUS = True 71 | print(""" 72 | ! ALERT: possible copied replay detected ! 73 | """) 74 | print("Suspicious average of similarity: {0:.4f} with {top_player}'s replay".format(average_value, top_player=rp_api[2])) 75 | 76 | # key pressed 77 | log_print("\nCases where the same keys were pressed: {}%%".format(comparison[1]) + 78 | "\nCases where the pressed keys were different: {}%%".format(comparison[2])) 79 | 80 | if comparison[1] >= 95 and rp_api[2] != TARGET_REPLAY.player_name: 81 | SUSPICIOUS = True 82 | print("Suspicious same keys pressed percentage: {0:.2f}% with {top_player}'s replay\n".format(comparison[1], top_player=rp_api[2])) 83 | 84 | if not SUSPICIOUS: 85 | print("Nothing suspicious going on here!") 86 | 87 | #comparing data from downloaded osr files 88 | elif(len(sys.argv) == 2): 89 | #download top 50 replays using account login method 90 | download.login(TARGET_REPLAY.beatmap_hash) 91 | DIRECTORY = os.getcwd() + "/replays" 92 | PATTERN = "*.osr" 93 | 94 | for dir, _, _ in os.walk(DIRECTORY): 95 | TOP_50_REPLAYS.extend(iglob(os.path.join(dir, PATTERN))) 96 | 97 | if not TOP_50_REPLAYS: 98 | sys.exit("Beatmap not ranked, can't download replays!") 99 | 100 | print("""\n 101 | 102 | ---- Analysis Results ---- 103 | """) 104 | 105 | SUSPICIOUS = False 106 | for rp in TOP_50_REPLAYS: 107 | replay_to_check = parse_replay_file(rp) 108 | rp_events = dontsteal.get_events_per_second(replay_to_check) 109 | comparison = dontsteal.compare_data(TARGET_REPLAY_EVENTS, rp_events) 110 | 111 | log_print("\nComparing to {}'s replay".format(replay_to_check.player_name)) 112 | 113 | # closeness 114 | log_print("Lowest values:") 115 | suspicious_low_values = True 116 | for values in sorted(comparison[0])[1:11]: 117 | if values >= 1: 118 | suspicious_low_values = False 119 | log_print(values) 120 | 121 | if suspicious_low_values and replay_to_check.player_name != TARGET_REPLAY.player_name: 122 | SUSPICIOUS = True 123 | print("\nSuspicious lowest values with {top_player}'s replay".format(top_player=replay_to_check.player_name)) 124 | 125 | log_print("\nAverage of similarity:") 126 | average_value = sum(comparison[0]) / len(comparison[0]) 127 | log_print(average_value) 128 | 129 | if average_value <= 15 and replay_to_check.player_name != TARGET_REPLAY.player_name: 130 | SUSPICIOUS = True 131 | print(""" 132 | ! ALERT: possible copied replay detected ! 133 | """) 134 | print("Suspicious average of similarity: {0:.4f} with {top_player}'s replay".format(average_value, top_player=replay_to_check.player_name)) 135 | 136 | # key pressed 137 | log_print("\nCases where the same keys were pressed: {}%%".format(comparison[1]) + 138 | "\nCases where the pressed keys were different: {}%%".format(comparison[2])) 139 | 140 | if comparison[1] >= 95 and replay_to_check.player_name != TARGET_REPLAY.player_name: 141 | SUSPICIOUS = True 142 | print("Suspicious same keys pressed percentage: {0:.2f}% with {top_player}'s replay\n".format(comparison[1], top_player=replay_to_check.player_name)) 143 | 144 | if not SUSPICIOUS: 145 | print("Nothing suspicious going on here!") 146 | 147 | shutil.rmtree(os.getcwd() + "/replays") 148 | 149 | 150 | try: 151 | with open("analysis_log.txt", "w") as f: 152 | f.write(OUTPUT) 153 | f.close() 154 | except OSError as error: 155 | print("OS Error: {0}".format(error)) 156 | sys.exit(1) 157 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "", 3 | "password": "", 4 | "osu_api_key": "" 5 | } -------------------------------------------------------------------------------- /dontsteal.py: -------------------------------------------------------------------------------- 1 | """dontsteal - stop stealing replays and git gud scrub!""" 2 | import math 3 | import sys 4 | from osrparse.replay import parse_replay_file 5 | from osrparse.enums import GameMode, Mod 6 | 7 | def analyze(replay): 8 | """Prints some common info about the replay""" 9 | if replay.game_mode is GameMode.Standard and replay.game_version >= 20140226: 10 | print("REPLAY INFO") 11 | print("Played by " + replay.player_name + " on " + replay.timestamp.__format__("%d/%m/%y %H:%M")) 12 | print("Mods used:") 13 | for mods_used in replay.mod_combination: 14 | print(str(mods_used).split("Mod.")[1]) 15 | score = ["\nTotal Score: {}".format(replay.score), 16 | "300s: {}".format(replay.number_300s), 17 | "100s: {}".format(replay.number_100s), 18 | "50s: {}".format(replay.number_50s), 19 | "Gekis: {}".format(replay.gekis), 20 | "Katus: {}".format(replay.katus), 21 | "Misses: {}".format(replay.misses), 22 | "Max Combo: {}".format(replay.max_combo)] 23 | for score_data in score: 24 | print(score_data) 25 | if replay.is_perfect_combo: 26 | print("Perfect Combo!\n") 27 | else: 28 | print("") 29 | else: 30 | raise ValueError("Can't analyze this replay, it might be too old or not for osu!standard.") 31 | return 32 | 33 | 34 | def get_events_per_second(replay): 35 | """Gets coordinates and key pressed per second""" 36 | events = [] 37 | time = 0 38 | 39 | for event in replay.play_data: 40 | time += event.time_since_previous_action 41 | if 1000*len(events) <= time: 42 | new_y = event.y if Mod.HardRock not in replay.mod_combination else 384-event.y 43 | events.append([event.x, new_y, event.keys_pressed]) 44 | return events 45 | 46 | 47 | def get_events_per_second_api(replay, mods): 48 | """Gets coordinates and key pressed per second for API""" 49 | events = [] 50 | time = 0 51 | replay_events = replay.split(",") 52 | 53 | for event in replay_events: 54 | values = event.split("|") 55 | try: 56 | time += float(values[0]) 57 | except ValueError: 58 | continue 59 | if 1000*len(events) <= time: 60 | new_y = float(values[2]) if "HR" not in mods else 384-float(values[2]) 61 | events.append([float(values[1]), new_y, float(values[3])]) 62 | return events 63 | 64 | 65 | def compare_data(positions1, positions2): 66 | """Compares coordinates and key pressed between the two replays""" 67 | length = len(positions1) if len(positions1) <= len(positions2) else len(positions2) 68 | closeness = [] 69 | same_keys_pressed = 0 70 | not_same_keys_pressed = 0 71 | 72 | for rep_value in range(0, length - 1): 73 | first_p = positions1[rep_value] 74 | second_p = positions2[rep_value] 75 | x_value = first_p[0] - second_p[0] 76 | y_value = first_p[1] - second_p[1] 77 | closeness.append(math.sqrt(x_value ** 2 + y_value ** 2)) 78 | if first_p[2] == second_p[2]: 79 | same_keys_pressed += 1 80 | else: 81 | not_same_keys_pressed += 1 82 | 83 | same_key_percentage = (100 * same_keys_pressed) / (same_keys_pressed + not_same_keys_pressed) 84 | different_key_percentage = 100 - same_key_percentage 85 | return closeness, same_key_percentage, different_key_percentage 86 | 87 | 88 | if __name__ == "__main__": 89 | first_replay = parse_replay_file(sys.argv[1]) 90 | second_replay = parse_replay_file(sys.argv[2]) 91 | 92 | if(first_replay.beatmap_hash != second_replay.beatmap_hash): 93 | sys.exit(""" 94 | ! ERROR: beatmap is not the same ! 95 | """) 96 | elif(first_replay.player_name == second_replay.player_name): 97 | sys.exit(""" 98 | ! ERROR: replays from the same player ! 99 | """) 100 | 101 | print(""" 102 | --- 1st Replay Data --- 103 | """) 104 | analyze(first_replay) 105 | print(""" 106 | --- 2nd Replay Data --- 107 | """) 108 | analyze(second_replay) 109 | 110 | print(""" 111 | ---- Analysis Results ---- 112 | """) 113 | first_replay_positions = get_events_per_second(first_replay) 114 | second_replay_positions = get_events_per_second(second_replay) 115 | comparison = compare_data(first_replay_positions, second_replay_positions) 116 | avg_similarity = sum(comparison[0]) / len(comparison[0]) 117 | same_keys = comparison[1] 118 | different_keys = comparison[2] 119 | 120 | print("\nLowest values:") 121 | for lowest_values in sorted(comparison[0])[2:12]: 122 | print(lowest_values) 123 | 124 | print("\nAverage of similarity:") 125 | print("{0:.4f}\n".format(avg_similarity)) 126 | 127 | if (avg_similarity) <= 15: 128 | print(""" 129 | ! ALERT: possible copied replay ! 130 | \n""") 131 | 132 | print("Cases where the same keys were pressed: {0:.2f}%\n".format(same_keys) + 133 | "Cases where the pressed keys were different: {0:.2f}%\n".format(different_keys) + 134 | "(Might not be accurate for some beatmaps)") 135 | -------------------------------------------------------------------------------- /download.py: -------------------------------------------------------------------------------- 1 | """Download replays from osu! website""" 2 | import http.cookiejar 3 | import urllib.parse 4 | import urllib.request 5 | import sys 6 | import json 7 | import os 8 | import requests 9 | 10 | JAR = http.cookiejar.CookieJar() 11 | OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(JAR)) 12 | 13 | with open('config.json', 'r') as f: 14 | CONFIG = json.load(f) 15 | f.close() 16 | 17 | def get_json(url): 18 | """Gets JSON data from a given URL""" 19 | try: 20 | data = requests.get(url=url).json() 21 | return data 22 | except requests.exceptions.Timeout: 23 | data = requests.get(url=url).json() 24 | except requests.exceptions.TooManyRedirects: 25 | print("Invalid link given") 26 | except requests.exceptions.RequestException as err: 27 | print(err) 28 | 29 | 30 | def login(beatmap_md5): 31 | """Responsible for logging into the osu! website """ 32 | print("Attempting to log into the osu! website...") 33 | payload = { 34 | 'username': CONFIG['username'], 35 | 'password': CONFIG['password'], 36 | 'redirect': 'https://osu.ppy.sh/forum/ucp.php', 37 | 'sid': '', 38 | 'login': 'login' 39 | } 40 | payload = urllib.parse.urlencode(payload).encode("utf-8") 41 | response = OPENER.open("https://osu.ppy.sh/forum/ucp.php?mode=login", payload) 42 | data = bytes(str(response.read()), "utf-8").decode("unicode_escape") 43 | 44 | #check if invalid credentials were given 45 | if "incorrect password" in data: 46 | sys.exit("You have specified an invalid password. Please check config.json") 47 | 48 | print("Successfully logged into the osu! website!") 49 | return get_scores(beatmap_md5) 50 | 51 | 52 | def get_scores(beatmap_md5): 53 | """Gets all scores for a given beatmap.""" 54 | #get beatmap_id from md5 hash 55 | url = 'https://osu.ppy.sh/api/get_beatmaps?k={}&h={}&mode=0&limit=50'.format(CONFIG['osu_api_key'], beatmap_md5) 56 | beatmap_data = get_json(url) 57 | 58 | if len(beatmap_data) < 1: 59 | sys.exit("The beatmap is either invalid or not ranked on osu!") 60 | 61 | beatmap_data_string = """ 62 | ------------------------------------------------ 63 | | Comparing Replays For Map: 64 | | Artist: {} 65 | | Title: {} 66 | | Beatmap Id: {} 67 | ------------------------------------------------ 68 | """.format(beatmap_data[0]['artist'], beatmap_data[0]['title'], beatmap_data[0]['beatmap_id']) 69 | print(beatmap_data_string) 70 | 71 | #get list of score ids from beatmap 72 | score_url = 'https://osu.ppy.sh/api/get_scores?k={}&b={}&mode=0&limit=50'.format(CONFIG['osu_api_key'], beatmap_data[0]['beatmap_id']) 73 | score_data = get_json(score_url) 74 | 75 | score_ids = [] 76 | for score in score_data: 77 | score_ids.append(score['score_id']) 78 | 79 | return download_replays(score_ids) 80 | 81 | 82 | def download_replays(score_ids): 83 | """Takes a list of scoreIds and downloads the replay to a new directory.""" 84 | #create a new path for the replays to be housed. 85 | new_path = os.getcwd() + "/" + "replays" 86 | if not os.path.exists(new_path): 87 | os.makedirs(new_path) 88 | 89 | for score_id in score_ids: 90 | try: 91 | directory = os.path.join(new_path) 92 | full_path = directory + "/" + str(score_id) + ".osr" 93 | print("\rDownloading replay: {}..." .format(score_id), end="") 94 | 95 | url = 'https://osu.ppy.sh/web/osu-getreplay.php?c={}&m=0'.format(score_id) 96 | f_2 = OPENER.open(url, {}) 97 | data = f_2.read() 98 | with open(full_path, 'wb') as code: 99 | code.write(data) 100 | code.close() 101 | except IOError as err: 102 | print(err) 103 | sys.exit() 104 | -------------------------------------------------------------------------------- /mods.py: -------------------------------------------------------------------------------- 1 | """Score mods handler""" 2 | MODS = ['NF', 'EZ', '', 'HD', 'HR', 'SD', 'DT', 'RX', 'HT', 'NC', 3 | 'FL', 'AP', 'SO', 'AU', 'PF', 'K4', 'K5', 'K6', 'K7', 'K8', 4 | 'FI', 'RD', 'LM', '', 'K9', 'K10', 'K1', 'K3', 'K2'] 5 | 6 | 7 | def get_mods(bit_field): 8 | """Takes the index and multiplies for its bit field""" 9 | used_mods = [name for index, name in enumerate(MODS) if 2**index & bit_field] 10 | return used_mods 11 | -------------------------------------------------------------------------------- /osuapi.py: -------------------------------------------------------------------------------- 1 | """Get useful stuff from the osu!api""" 2 | import base64 3 | import lzma 4 | import time 5 | import json 6 | import requests 7 | import mods 8 | 9 | try: 10 | with open('config.json', 'r') as f: 11 | CONFIG = json.load(f) 12 | OSU_API_KEY = CONFIG['osu_api_key'] 13 | f.close() 14 | except OSError as err: 15 | print("OS Error {0}".format(err)) 16 | 17 | def get_users_from_beatmap(beatmap_id): 18 | """Get users from top 100 scores.""" 19 | parameters = {"k": OSU_API_KEY, "b": beatmap_id, "m": 0} 20 | api_request = requests.get("http://osu.ppy.sh/api/get_scores", params=parameters) 21 | json_data = api_request.json() 22 | users = [] 23 | for item in json_data: 24 | enabled_mods = mods.get_mods(int(item["enabled_mods"])) 25 | users.append([item["username"], enabled_mods]) 26 | return users 27 | 28 | 29 | def get_replays(beatmap_id): 30 | """Get the replay data of a user's score on a beatmap.""" 31 | replay_data = [] 32 | for i, user in enumerate(get_users_from_beatmap(beatmap_id)): 33 | parameters = {"k": OSU_API_KEY, "b": beatmap_id, "m": 0, "u": user[0]} 34 | 35 | def download_replay(): 36 | """Downloads and decodes a replay into a usable list""" 37 | req = requests.get("https://osu.ppy.sh/api/get_replay", params=parameters).json() 38 | try: 39 | print("\r({}/50) Downloading {}'s replay... " .format(i+1, user[0]), end="") 40 | replay_data.append([lzma.decompress( 41 | base64.b64decode(req["content"]) 42 | ).decode("utf-8"),user[1], user[0]]) 43 | time.sleep(7) 44 | except KeyError as err: 45 | if req["error"] == "Requesting too fast! Slow your operation, cap'n!": 46 | print("Too fast! Trying again in 15 seconds...") 47 | time.sleep(15) 48 | download_replay() 49 | else: 50 | print(req) 51 | raise err 52 | 53 | download_replay() 54 | 55 | print("") 56 | return replay_data 57 | 58 | 59 | def get_beatmap(map_hash): 60 | """Returns beatmap ID from given beatmap hash""" 61 | parameters = {"k": OSU_API_KEY, "h": map_hash} 62 | requ = requests.get("https://osu.ppy.sh/api/get_beatmaps", params=parameters).json() 63 | return requ[0]["beatmap_id"] 64 | 65 | 66 | def get_beatmap_info(beatmap_hash): 67 | """Retrieve general beatmap information.""" 68 | parameters = {"k": OSU_API_KEY, "h": beatmap_hash} 69 | b_req = requests.get("https://osu.ppy.sh/api/get_beatmaps", params=parameters).json() 70 | 71 | beatmap_data_string = """ 72 | ------------------------------------------------ 73 | | Comparing Replays For Map: 74 | | Artist: {} 75 | | Title: {} 76 | | Beatmap Id: {} 77 | ------------------------------------------------ 78 | """.format(b_req[0]['artist'], b_req[0]['title'], b_req[0]['beatmap_id']) 79 | print(beatmap_data_string) 80 | 81 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | osrparse 2 | requests --------------------------------------------------------------------------------