├── run_redeemer.bat ├── requirements.txt ├── README.md ├── .gitignore └── humblesteamkeysredeemer.py /run_redeemer.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Installing dependencies... 3 | py -3 -m pip install -r requirements.txt 4 | echo Running 5 | py -3 humblesteamkeysredeemer.py 6 | set /p=Press ENTER to close terminal 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fuzzywuzzy>=0.17.0 2 | requests>=2.25.1 3 | selenium>=4.10.0 4 | pwinput>=1.0.3 5 | steam @ git+https://github.com/FailSpy/steam-py-lib@master # patched steam python library using changes from artur1214 until fixed on main repo 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Humble Steam Key Redeemer 2 | Python utility script to extract Humble keys, and redeem them on Steam automagically by detecting when a game is already owned on Steam. 3 | 4 | This is primarily designed to be a set-it-and-forget-it tool that maximizes successful entry of keys into Steam, assuring that no Steam game goes unredeemed. 5 | 6 | This script will login to both Humble and Steam, automating the whole process. It's not perfect as I made this mostly for my own personal case and couldn't test all possibilities so YMMV. Feel free to send submit an issue if you do bump into issues. 7 | 8 | Any revealing and redeeming the script does will output to spreadsheet files based on their actions for you to easily review what actions it took and whether it redeemed, skipped, or failed on specific keys. 9 | 10 | ## Modes 11 | ### Auto-Redeem Mode (Steam) 12 | Find Steam games from Humble that are unowned by your Steam user, and ONLY of those that are unowned, redeem on Steam revealed keys (This EXCLUDES non-Steam keys and unclaimed Humble Choice games) 13 | 14 | If you choose to reveal keys in this mode, it will only reveal keys that it goes to redeem (ignoring those that are detected as already owned) 15 | ### Export Mode 16 | Find all games from Humble, optionally revealing all unrevealed keys, and output them to a CSV (comes with an optional Steam ownership column). 17 | 18 | This is great if you want a manual review of what games are in your keys list that you may have missed. 19 | ### Humble Chooser Mode 20 | For those subscribed to Humble Choice, this mode will find any Humble Monthly/Choice that has unclaimed choices, and will let you select, reveal, and optionally autoredeem on Steam the keys you select 21 | 22 | # 23 | ### Notes 24 | 25 | To remove an already added account, delete the associated `.(humble|steam)cookies` file. 26 | 27 | ### Dependencies 28 | 29 | Requires Python version 3.6 or above 30 | 31 | - `steam`: [ValvePython/steam](https://github.com/ValvePython/steam) 32 | - `fuzzywuzzy`: [seatgeek/fuzzywuzzy](https://github.com/seatgeek/fuzzywuzzy) 33 | - `requests`: [requests](https://requests.readthedocs.io/en/master/) 34 | - `selenium`: [selenium](https://www.selenium.dev/) 35 | - `pwinput`: [pwinput](https://github.com/asweigart/pwinput) 36 | - `python-Levenshtein`: [ztane/python-Levenshtein](https://github.com/ztane/python-Levenshtein) **OPTIONAL** 37 | 38 | Install the required dependencies with 39 | ``` 40 | pip install -r requirements.txt 41 | ``` 42 | If you want to install `python-Levenshtein`: 43 | ``` 44 | pip install python-Levenshtein 45 | ``` 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | #Used files 6 | *.csv 7 | .*cookies 8 | skipped.txt 9 | 10 | ### Python ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | pytestdebug.log 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | doc/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # Celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | pythonenv* 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # profiling data 148 | .prof 149 | 150 | # End of https://www.toptal.com/developers/gitignore/api/python 151 | 152 | .idea/ 153 | .humblecookies -------------------------------------------------------------------------------- /humblesteamkeysredeemer.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from selenium import webdriver 3 | from selenium.common.exceptions import WebDriverException 4 | from fuzzywuzzy import fuzz 5 | import steam.webauth as wa 6 | import time 7 | import pickle 8 | from pwinput import pwinput 9 | import os 10 | import json 11 | import sys 12 | import webbrowser 13 | import os 14 | from base64 import b64encode 15 | import atexit 16 | import signal 17 | from http.client import responses 18 | 19 | #patch steam webauth for password feedback 20 | wa.getpass = pwinput 21 | 22 | if __name__ == "__main__": 23 | sys.stderr = open('error.log','a') 24 | 25 | # Humble endpoints 26 | HUMBLE_LOGIN_PAGE = "https://www.humblebundle.com/login" 27 | HUMBLE_KEYS_PAGE = "https://www.humblebundle.com/home/library" 28 | HUMBLE_SUB_PAGE = "https://www.humblebundle.com/subscription/" 29 | 30 | HUMBLE_LOGIN_API = "https://www.humblebundle.com/processlogin" 31 | HUMBLE_REDEEM_API = "https://www.humblebundle.com/humbler/redeemkey" 32 | HUMBLE_ORDERS_API = "https://www.humblebundle.com/api/v1/user/order" 33 | HUMBLE_ORDER_DETAILS_API = "https://www.humblebundle.com/api/v1/order/" 34 | HUMBLE_SUB_API = "https://www.humblebundle.com/api/v1/subscriptions/humble_monthly/subscription_products_with_gamekeys/" 35 | 36 | HUMBLE_PAY_EARLY = "https://www.humblebundle.com/subscription/payearly" 37 | HUMBLE_CHOOSE_CONTENT = "https://www.humblebundle.com/humbler/choosecontent" 38 | 39 | # Steam endpoints 40 | STEAM_KEYS_PAGE = "https://store.steampowered.com/account/registerkey" 41 | STEAM_USERDATA_API = "https://store.steampowered.com/dynamicstore/userdata/" 42 | STEAM_REDEEM_API = "https://store.steampowered.com/account/ajaxregisterkey/" 43 | STEAM_APP_LIST_API = "https://api.steampowered.com/ISteamApps/GetAppList/v2/" 44 | 45 | # May actually be able to do without these, but for now they're in. 46 | headers = { 47 | "Content-Type": "application/x-www-form-urlencoded", 48 | "Accept": "application/json, text/javascript, */*; q=0.01", 49 | } 50 | 51 | 52 | def find_dict_keys(node, kv, parent=False): 53 | if isinstance(node, list): 54 | for i in node: 55 | for x in find_dict_keys(i, kv, parent): 56 | yield x 57 | elif isinstance(node, dict): 58 | if kv in node: 59 | if parent: 60 | yield node 61 | else: 62 | yield node[kv] 63 | for j in node.values(): 64 | for x in find_dict_keys(j, kv, parent): 65 | yield x 66 | 67 | getHumbleOrders = ''' 68 | var done = arguments[arguments.length - 1]; 69 | var list = '%optional%'; 70 | if (list){ 71 | list = JSON.parse(list); 72 | } else { 73 | list = []; 74 | } 75 | var getHumbleOrderDetails = async (list) => { 76 | const HUMBLE_ORDERS_API_URL = 'https://www.humblebundle.com/api/v1/user/order'; 77 | const HUMBLE_ORDER_DETAILS_API = 'https://www.humblebundle.com/api/v1/order/'; 78 | 79 | try { 80 | var orders = [] 81 | if(list.length){ 82 | orders = list.map(item => ({ gamekey: item })); 83 | } else { 84 | const response = await fetch(HUMBLE_ORDERS_API_URL); 85 | orders = await response.json(); 86 | } 87 | const orderDetailsPromises = orders.map(async (order) => { 88 | const orderDetailsUrl = `${HUMBLE_ORDER_DETAILS_API}${order['gamekey']}?all_tpkds=true`; 89 | const orderDetailsResponse = await fetch(orderDetailsUrl); 90 | const orderDetails = await orderDetailsResponse.json(); 91 | return orderDetails; 92 | }); 93 | 94 | const orderDetailsArray = await Promise.all(orderDetailsPromises); 95 | return orderDetailsArray; 96 | } catch (error) { 97 | console.error('Error:', error); 98 | return []; 99 | } 100 | }; 101 | 102 | getHumbleOrderDetails(list).then(r => {done(r)}); 103 | ''' 104 | 105 | fetch_cmd = ''' 106 | var done = arguments[arguments.length - 1]; 107 | var formData = new FormData(); 108 | const jsonData = JSON.parse(atob('{formData}')); 109 | 110 | for (const key in jsonData) {{ 111 | formData.append(key,jsonData[key]) 112 | }} 113 | 114 | fetch("{url}", {{ 115 | "headers": {{ 116 | "csrf-prevention-token": "{csrf}" 117 | }}, 118 | "body": formData, 119 | "method": "POST", 120 | }}).then(r => {{ r.json().then( v=>{{done([r.status,v])}} ) }} ); 121 | ''' 122 | 123 | def perform_post(driver,url,payload): 124 | json_payload = b64encode(json.dumps(payload).encode('utf-8')).decode('ascii') 125 | csrf = driver.get_cookie('csrf_cookie') 126 | csrf = csrf['value'] if csrf is not None else '' 127 | if csrf is None: 128 | csrf = '' 129 | script = fetch_cmd.format(formData=json_payload,url=url,csrf=csrf) 130 | 131 | return driver.execute_async_script(fetch_cmd.format(formData=json_payload,url=url,csrf=csrf)) 132 | 133 | def process_quit(driver): 134 | def quit_on_exit(*args): 135 | driver.quit() 136 | 137 | atexit.register(quit_on_exit) 138 | signal.signal(signal.SIGTERM,quit_on_exit) 139 | signal.signal(signal.SIGINT,quit_on_exit) 140 | 141 | def get_headless_driver(): 142 | possibleDrivers = [(webdriver.Firefox,webdriver.FirefoxOptions),(webdriver.Chrome,webdriver.ChromeOptions)] 143 | driver = None 144 | 145 | exceptions = [] 146 | for d,opt in possibleDrivers: 147 | try: 148 | options = opt() 149 | if d == webdriver.Chrome: 150 | options.add_argument("--headless=new") 151 | else: 152 | options.add_argument("-headless") 153 | driver = d(options=options) 154 | process_quit(driver) # make sure driver closes when we close 155 | return driver 156 | except WebDriverException as e: 157 | exceptions.append(('chrome:' if d == webdriver.Chrome else 'firefox:',e)) 158 | continue 159 | cls() 160 | print("This script needs either Chrome or Firefox to be installed and the respective Web Driver for it to be configured (usually simplest is by placing it in the folder with the script)") 161 | print("") 162 | print("https://www.browserstack.com/guide/geckodriver-selenium-python") 163 | print("") 164 | print("Potential configuration hints:") 165 | for browser,exception in exceptions: 166 | print("") 167 | print(browser,exception.msg) 168 | 169 | time.sleep(30) 170 | sys.exit() 171 | 172 | MODE_PROMPT = """Welcome to the Humble Exporter! 173 | Which key export mode would you like to use? 174 | 175 | [1] Auto-Redeem 176 | [2] Export keys 177 | [3] Humble Choice chooser 178 | """ 179 | def prompt_mode(order_details,humble_session): 180 | mode = None 181 | while mode not in ["1","2","3"]: 182 | print(MODE_PROMPT) 183 | mode = input("Choose 1, 2, or 3: ").strip() 184 | if mode in ["1","2","3"]: 185 | return mode 186 | print("Invalid mode") 187 | return mode 188 | 189 | 190 | def valid_steam_key(key): 191 | # Steam keys are in the format of AAAAA-BBBBB-CCCCC 192 | if not isinstance(key, str): 193 | return False 194 | key_parts = key.split("-") 195 | return ( 196 | len(key) == 17 197 | and len(key_parts) == 3 198 | and all(len(part) == 5 for part in key_parts) 199 | ) 200 | 201 | 202 | def try_recover_cookies(cookie_file, session): 203 | try: 204 | cookies = pickle.load(open(cookie_file,"rb")) 205 | if type(session) is requests.Session: 206 | # handle Steam session 207 | session.cookies.update(cookies) 208 | else: 209 | # handle WebDriver 210 | for cookie in cookies: 211 | session.add_cookie(cookie) 212 | return True 213 | except Exception as e: 214 | return False 215 | 216 | 217 | def export_cookies(cookie_file, session): 218 | try: 219 | cookies = None 220 | if type(session) is requests.Session: 221 | # handle Steam session 222 | cookies = session.cookies 223 | else: 224 | # handle WebDriver 225 | cookies = session.get_cookies() 226 | pickle.dump(cookies, open(cookie_file,"wb")) 227 | return True 228 | except: 229 | return False 230 | 231 | is_logged_in = ''' 232 | var done = arguments[arguments.length-1]; 233 | 234 | fetch("https://www.humblebundle.com/home/library").then(r => {done(!r.redirected)}) 235 | ''' 236 | 237 | def verify_logins_session(session): 238 | # Returns [humble_status, steam_status] 239 | if type(session) is requests.Session: 240 | loggedin = session.get(STEAM_KEYS_PAGE, allow_redirects=False).status_code not in (301,302) 241 | return [False,loggedin] 242 | else: 243 | return [session.execute_async_script(is_logged_in),False] 244 | 245 | def do_login(driver,payload): 246 | auth,login_json = perform_post(driver,HUMBLE_LOGIN_API,payload) 247 | if auth not in (200,401): 248 | print(f"humblebundle.com has responded with an error (HTTP status code {auth}: {responses[auth]}).") 249 | time.sleep(30) 250 | sys.exit() 251 | return auth,login_json 252 | 253 | def humble_login(driver): 254 | cls() 255 | driver.get(HUMBLE_LOGIN_PAGE) 256 | # Attempt to use saved session 257 | if try_recover_cookies(".humblecookies", driver) and verify_logins_session(driver)[0]: 258 | return True 259 | 260 | # Saved session didn't work 261 | authorized = False 262 | while not authorized: 263 | username = input("Humble Email: ") 264 | password = pwinput() 265 | 266 | 267 | payload = { 268 | "access_token": "", 269 | "access_token_provider_id": "", 270 | "goto": "/", 271 | "qs": "", 272 | "username": username, 273 | "password": password, 274 | } 275 | 276 | auth,login_json = do_login(driver,payload) 277 | 278 | if "errors" in login_json and "username" in login_json["errors"]: 279 | # Unknown email OR mismatched password 280 | print(login_json["errors"]["username"][0]) 281 | continue 282 | 283 | while "humble_guard_required" in login_json or "two_factor_required" in login_json: 284 | # There may be differences for Humble's SMS 2FA, haven't tested. 285 | if "humble_guard_required" in login_json: 286 | humble_guard_code = input("Please enter the Humble security code: ") 287 | payload["guard"] = humble_guard_code.upper() 288 | # Humble security codes are case-sensitive via API, but luckily it's all uppercase! 289 | auth,login_json = do_login(driver,payload) 290 | 291 | if ( 292 | "user_terms_opt_in_data" in login_json 293 | and login_json["user_terms_opt_in_data"]["needs_to_opt_in"] 294 | ): 295 | # Nope, not messing with this. 296 | print( 297 | "There's been an update to the TOS, please sign in to Humble on your browser." 298 | ) 299 | sys.exit() 300 | elif ( 301 | "two_factor_required" in login_json and 302 | "errors" in login_json 303 | and "authy-input" in login_json["errors"] 304 | ): 305 | code = input("Please enter 2FA code: ") 306 | payload["code"] = code 307 | auth,login_json = do_login(driver,payload) 308 | elif "errors" in login_json: 309 | print("Unexpected login error detected.") 310 | print(login_json["errors"]) 311 | raise Exception(login_json) 312 | sys.exit() 313 | 314 | if auth == 200: 315 | break 316 | 317 | export_cookies(".humblecookies", driver) 318 | return True 319 | 320 | 321 | def steam_login(): 322 | # Sign into Steam web 323 | 324 | # Attempt to use saved session 325 | r = requests.Session() 326 | if try_recover_cookies(".steamcookies", r) and verify_logins_session(r)[1]: 327 | return r 328 | 329 | # Saved state doesn't work, prompt user to sign in. 330 | s_username = input("Steam Username: ") 331 | user = wa.WebAuth(s_username) 332 | session = user.cli_login() 333 | export_cookies(".steamcookies", session) 334 | return session 335 | 336 | 337 | def redeem_humble_key(sess, tpk): 338 | # Keys need to be 'redeemed' on Humble first before the Humble API gives the user a Steam key. 339 | # This triggers that for a given Humble key entry 340 | payload = {"keytype": tpk["machine_name"], "key": tpk["gamekey"], "keyindex": tpk["keyindex"]} 341 | status,respjson = perform_post(sess, HUMBLE_REDEEM_API, payload) 342 | 343 | if status != 200 or "error_msg" in respjson or not respjson["success"]: 344 | print("Error redeeming key on Humble for " + tpk["human_name"]) 345 | if("error_msg" in respjson): 346 | print(respjson["error_msg"]) 347 | return "" 348 | try: 349 | return respjson["key"] 350 | except: 351 | return respjson 352 | 353 | 354 | def get_month_data(humble_session,month): 355 | # No real API for this, seems to just be served on the webpage. 356 | if type(humble_session) is not requests.Session: 357 | raise Exception("get_month_data needs a configured requests session") 358 | r = humble_session.get(HUMBLE_SUB_PAGE + month["product"]["choice_url"]) 359 | 360 | data_indicator = f'")[0].strip() 362 | jsondata = json.loads(jsondata) 363 | return jsondata["contentChoiceOptions"] 364 | 365 | 366 | def get_choices(humble_session,order_details): 367 | months = [ 368 | month for month in order_details 369 | if "choice_url" in month["product"] 370 | ] 371 | 372 | # Oldest to Newest order 373 | months = sorted(months,key=lambda m: m["created"]) 374 | request_session = requests.Session() 375 | for cookie in humble_session.get_cookies(): 376 | # convert cookies to requests 377 | request_session.cookies.set(cookie['name'],cookie['value'],domain=cookie['domain'].replace('www.',''),path=cookie['path']) 378 | 379 | choices = [] 380 | for month in months: 381 | if month["choices_remaining"] > 0 or month["product"].get("is_subs_v3_product",False): # subs v3 products don't advertise choices, need to get them exhaustively 382 | chosen_games = set(find_dict_keys(month["tpkd_dict"],"machine_name")) 383 | 384 | month["choice_data"] = get_month_data(request_session,month) 385 | if not month["choice_data"].get('canRedeemGames',True): 386 | month["available_choices"] = [] 387 | continue 388 | 389 | v3 = not month["choice_data"].get("usesChoices",True) 390 | 391 | # Needed for choosing 392 | if v3: 393 | identifier = "initial" 394 | choice_options = month["choice_data"]["contentChoiceData"]["game_data"] 395 | else: 396 | identifier = "initial" if "initial" in month["choice_data"]["contentChoiceData"] else "initial-classic" 397 | 398 | if identifier not in month["choice_data"]["contentChoiceData"]: 399 | for key in month["choice_data"]["contentChoiceData"].keys(): 400 | if "content_choices" in month["choice_data"]["contentChoiceData"][key]: 401 | identifier = key 402 | 403 | choice_options = month["choice_data"]["contentChoiceData"][identifier]["content_choices"] 404 | 405 | # Exclude games that have already been chosen: 406 | month["available_choices"] = [ 407 | game[1] 408 | for game in choice_options.items() 409 | if set(find_dict_keys(game[1],"machine_name")).isdisjoint(chosen_games) 410 | ] 411 | 412 | month["parent_identifier"] = identifier 413 | if len(month["available_choices"]): 414 | yield month 415 | 416 | 417 | def _redeem_steam(session, key, quiet=False): 418 | # Based on https://gist.github.com/snipplets/2156576c2754f8a4c9b43ccb674d5a5d 419 | if key == "": 420 | return 0 421 | session_id = session.cookies.get_dict()["sessionid"] 422 | r = session.post(STEAM_REDEEM_API, data={"product_key": key, "sessionid": session_id}) 423 | blob = r.json() 424 | 425 | if blob["success"] == 1: 426 | for item in blob["purchase_receipt_info"]["line_items"]: 427 | print("Redeemed " + item["line_item_description"]) 428 | return 0 429 | else: 430 | error_code = blob.get("purchase_result_details") 431 | if error_code == None: 432 | # Sometimes purchase_result_details isn't there for some reason, try alt method 433 | error_code = blob.get("purchase_receipt_info") 434 | if error_code != None: 435 | error_code = error_code.get("result_detail") 436 | error_code = error_code or 53 437 | 438 | if error_code == 14: 439 | error_message = ( 440 | "The product code you've entered is not valid. Please double check to see if you've " 441 | "mistyped your key. I, L, and 1 can look alike, as can V and Y, and 0 and O. " 442 | ) 443 | elif error_code == 15: 444 | error_message = ( 445 | "The product code you've entered has already been activated by a different Steam account. " 446 | "This code cannot be used again. Please contact the retailer or online seller where the " 447 | "code was purchased for assistance. " 448 | ) 449 | elif error_code == 53: 450 | error_message = ( 451 | "There have been too many recent activation attempts from this account or Internet " 452 | "address. Please wait and try your product code again later. " 453 | ) 454 | elif error_code == 13: 455 | error_message = ( 456 | "Sorry, but this product is not available for purchase in this country. Your product key " 457 | "has not been redeemed. " 458 | ) 459 | elif error_code == 9: 460 | error_message = ( 461 | "This Steam account already owns the product(s) contained in this offer. To access them, " 462 | "visit your library in the Steam client. " 463 | ) 464 | elif error_code == 24: 465 | error_message = ( 466 | "The product code you've entered requires ownership of another product before " 467 | "activation.\n\nIf you are trying to activate an expansion pack or downloadable content, " 468 | "please first activate the original game, then activate this additional content. " 469 | ) 470 | elif error_code == 36: 471 | error_message = ( 472 | "The product code you have entered requires that you first play this game on the " 473 | "PlayStation®3 system before it can be registered.\n\nPlease:\n\n- Start this game on " 474 | "your PlayStation®3 system\n\n- Link your Steam account to your PlayStation®3 Network " 475 | "account\n\n- Connect to Steam while playing this game on the PlayStation®3 system\n\n- " 476 | "Register this product code through Steam. " 477 | ) 478 | elif error_code == 50: 479 | error_message = ( 480 | "The code you have entered is from a Steam Gift Card or Steam Wallet Code. Browse here: " 481 | "https://store.steampowered.com/account/redeemwalletcode to redeem it. " 482 | ) 483 | else: 484 | error_message = ( 485 | "An unexpected error has occurred. Your product code has not been redeemed. Please wait " 486 | "30 minutes and try redeeming the code again. If the problem persists, please contact Steam Support for ' 488 | "further assistance. " 489 | ) 490 | if error_code != 53 or not quiet: 491 | print(error_message) 492 | return error_code 493 | 494 | 495 | files = {} 496 | 497 | 498 | def write_key(code, key): 499 | global files 500 | 501 | filename = "redeemed.csv" 502 | if code == 15 or code == 9: 503 | filename = "already_owned.csv" 504 | elif code != 0: 505 | filename = "errored.csv" 506 | 507 | if filename not in files: 508 | files[filename] = open(filename, "a", encoding="utf-8-sig") 509 | key["human_name"] = key["human_name"].replace(",", ".") 510 | gamekey = key.get('gamekey') 511 | human_name = key.get("human_name") 512 | redeemed_key_val = key.get("redeemed_key_val") 513 | output = f"{gamekey},{human_name},{redeemed_key_val}\n" 514 | files[filename].write(output) 515 | files[filename].flush() 516 | 517 | 518 | def prompt_skipped(skipped_games): 519 | user_filtered = [] 520 | with open("skipped.txt", "w", encoding="utf-8-sig") as file: 521 | for skipped_game in skipped_games.keys(): 522 | file.write(skipped_game + "\n") 523 | 524 | print( 525 | f"Inside skipped.txt is a list of {len(skipped_games)} games that we think you already own, but aren't " 526 | f"completely sure " 527 | ) 528 | try: 529 | input( 530 | "Feel free to REMOVE from that list any games that you would like to try anyways, and when done press " 531 | "Enter to confirm. " 532 | ) 533 | except SyntaxError: 534 | pass 535 | if os.path.exists("skipped.txt"): 536 | with open("skipped.txt", "r", encoding="utf-8-sig") as file: 537 | user_filtered = [line.strip() for line in file] 538 | os.remove("skipped.txt") 539 | # Choose only the games that appear to be missing from user's skipped.txt file 540 | user_requested = [ 541 | skip_game 542 | for skip_name, skip_game in skipped_games.items() 543 | if skip_name not in user_filtered 544 | ] 545 | return user_requested 546 | 547 | 548 | def prompt_yes_no(question): 549 | ans = None 550 | answers = ["y","n"] 551 | while ans not in answers: 552 | prompt = f"{question} [{'/'.join(answers)}] " 553 | 554 | ans = input(prompt).strip().lower() 555 | if ans not in answers: 556 | print(f"{ans} is not a valid answer") 557 | continue 558 | else: 559 | return True if ans == "y" else False 560 | 561 | def get_owned_apps(steam_session): 562 | owned_content = steam_session.get(STEAM_USERDATA_API).json() 563 | owned_app_ids = owned_content["rgOwnedPackages"] + owned_content["rgOwnedApps"] 564 | owned_app_details = { 565 | app["appid"]: app["name"] 566 | for app in steam_session.get(STEAM_APP_LIST_API).json()["applist"]["apps"] 567 | if app["appid"] in owned_app_ids 568 | } 569 | return owned_app_details 570 | 571 | def match_ownership(owned_app_details, game, filter_live): 572 | threshold = 70 573 | best_match = (0, None) 574 | # Do a string search based on product names. 575 | matches = [ 576 | (fuzz.token_set_ratio(appname, game["human_name"]), appid) 577 | for appid, appname in owned_app_details.items() 578 | ] 579 | refined_matches = [ 580 | (fuzz.token_sort_ratio(owned_app_details[appid], game["human_name"]), appid) 581 | for score, appid in matches 582 | if score > threshold 583 | ] 584 | 585 | if filter_live and len(refined_matches) > 0: 586 | cls() 587 | best_match = max(refined_matches, key=lambda item: item[0]) 588 | if best_match[0] == 100: 589 | return best_match 590 | print("steam games you own") 591 | for match in refined_matches: 592 | print(f" {owned_app_details[match[1]]}: {match[0]}") 593 | if prompt_yes_no(f"Is \"{game['human_name']}\" in the above list?"): 594 | return refined_matches[0] 595 | else: 596 | return (0,None) 597 | else: 598 | if len(refined_matches) > 0: 599 | best_match = max(refined_matches, key=lambda item: item[0]) 600 | elif len(refined_matches) == 1: 601 | best_match = refined_matches[0] 602 | if best_match[0] < 35: 603 | best_match = (0,None) 604 | return best_match 605 | 606 | def prompt_filter_live(): 607 | mode = None 608 | while mode not in ["y","n"]: 609 | mode = input("You can either see a list of games we think you already own later in a file, or filter them now. Would you like to see them now? [y/n] ").strip() 610 | if mode in ["y","n"]: 611 | return mode 612 | else: 613 | print("Enter y or n") 614 | return mode 615 | 616 | def redeem_steam_keys(humble_session, humble_keys): 617 | session = steam_login() 618 | 619 | print("Successfully signed in on Steam.") 620 | print("Getting your owned content to avoid attempting to register keys already owned...") 621 | 622 | # Query owned App IDs according to Steam 623 | owned_app_details = get_owned_apps(session) 624 | 625 | noted_keys = [key for key in humble_keys if key["steam_app_id"] not in owned_app_details.keys()] 626 | skipped_games = {} 627 | unownedgames = [] 628 | 629 | # Some Steam keys come back with no Steam AppID from Humble 630 | # So we do our best to look up from AppIDs (no packages, because can't find an API for it) 631 | 632 | filter_live = prompt_filter_live() == "y" 633 | 634 | for game in noted_keys: 635 | best_match = match_ownership(owned_app_details,game,filter_live) 636 | if best_match[1] is not None and best_match[1] in owned_app_details.keys(): 637 | skipped_games[game["human_name"].strip()] = game 638 | else: 639 | unownedgames.append(game) 640 | 641 | print( 642 | "Filtered out game keys that you already own on Steam; {} keys unowned.".format( 643 | len(unownedgames) 644 | ) 645 | ) 646 | 647 | if len(skipped_games): 648 | # Skipped games uncertain to be owned by user. Let user choose 649 | unownedgames = unownedgames + prompt_skipped(skipped_games) 650 | print("{} keys will be attempted.".format(len(unownedgames))) 651 | # Preserve original order 652 | unownedgames = sorted(unownedgames,key=lambda g: humble_keys.index(g)) 653 | 654 | redeemed = [] 655 | 656 | for key in unownedgames: 657 | print(key["human_name"]) 658 | 659 | if key["human_name"] in redeemed or (key["steam_app_id"] != None and key["steam_app_id"] in redeemed): 660 | # We've bumped into a repeat of the same game! 661 | write_key(9,key) 662 | continue 663 | else: 664 | if key["steam_app_id"] != None: 665 | redeemed.append(key["steam_app_id"]) 666 | redeemed.append(key["human_name"]) 667 | 668 | if "redeemed_key_val" not in key: 669 | # This key is unredeemed via Humble, trigger redemption process. 670 | redeemed_key = redeem_humble_key(humble_session, key) 671 | key["redeemed_key_val"] = redeemed_key 672 | # Worth noting this will only persist for this loop -- does not get saved to unownedgames' obj 673 | 674 | if not valid_steam_key(key["redeemed_key_val"]): 675 | # Most likely humble gift link 676 | write_key(1, key) 677 | continue 678 | 679 | code = _redeem_steam(session, key["redeemed_key_val"]) 680 | animation = "|/-\\" 681 | seconds = 0 682 | while code == 53: 683 | """NOTE 684 | Steam seems to limit to about 50 keys/hr -- even if all 50 keys are legitimate *sigh* 685 | Even worse: 10 *failed* keys/hr 686 | Duplication counts towards Steam's _failure rate limit_, 687 | hence why we've worked so hard above to figure out what we already own 688 | """ 689 | current_animation = animation[seconds % len(animation)] 690 | print( 691 | f"Waiting for rate limit to go away (takes an hour after first key insert) {current_animation}", 692 | end="\r", 693 | ) 694 | time.sleep(1) 695 | seconds = seconds + 1 696 | if seconds % 60 == 0: 697 | # Try again every 60 seconds 698 | code = _redeem_steam(session, key["redeemed_key_val"], quiet=True) 699 | 700 | write_key(code, key) 701 | 702 | 703 | def export_mode(humble_session,order_details): 704 | cls() 705 | 706 | export_key_headers = ['human_name','redeemed_key_val','is_gift','key_type_human_name','is_expired','steam_ownership'] 707 | 708 | steam_session = None 709 | reveal_unrevealed = False 710 | confirm_reveal = False 711 | 712 | owned_app_details = None 713 | 714 | keys = [] 715 | 716 | print("Please configure your export:") 717 | export_steam_only = prompt_yes_no("Export only Steam keys?") 718 | export_revealed = prompt_yes_no("Export revealed keys?") 719 | export_unrevealed = prompt_yes_no("Export unrevealed keys?") 720 | if(not export_revealed and not export_unrevealed): 721 | print("That leaves 0 keys...") 722 | sys.exit() 723 | if(export_unrevealed): 724 | reveal_unrevealed = prompt_yes_no("Reveal all unrevealed keys? (This will remove your ability to claim gift links on these)") 725 | if(reveal_unrevealed): 726 | extra = "Steam " if export_steam_only else "" 727 | confirm_reveal = prompt_yes_no(f"Please CONFIRM that you would like ALL {extra}keys on Humble to be revealed, this can't be undone.") 728 | steam_config = prompt_yes_no("Would you like to sign into Steam to detect ownership on the export data?") 729 | 730 | if(steam_config): 731 | steam_session = steam_login() 732 | if(verify_logins_session(steam_session)[1]): 733 | owned_app_details = get_owned_apps(steam_session) 734 | 735 | desired_keys = "steam_app_id" if export_steam_only else "key_type_human_name" 736 | keylist = list(find_dict_keys(order_details,desired_keys,True)) 737 | 738 | for idx,tpk in enumerate(keylist): 739 | revealed = "redeemed_key_val" in tpk 740 | export = (export_revealed and revealed) or (export_unrevealed and not revealed) 741 | 742 | if(export): 743 | if(export_unrevealed and confirm_reveal): 744 | # Redeem key if user requests all keys to be revealed 745 | tpk["redeemed_key_val"] = redeem_humble_key(humble_session,tpk) 746 | 747 | if(owned_app_details != None and "steam_app_id" in tpk): 748 | # User requested Steam Ownership info 749 | owned = tpk["steam_app_id"] in owned_app_details.keys() 750 | if(not owned): 751 | # Do a search to see if user owns it 752 | best_match = match_ownership(owned_app_details,tpk,False) 753 | owned = best_match[1] is not None and best_match[1] in owned_app_details.keys() 754 | tpk["steam_ownership"] = owned 755 | 756 | keys.append(tpk) 757 | 758 | ts = time.strftime("%Y%m%d-%H%M%S") 759 | filename = f"humble_export_{ts}.csv" 760 | with open(filename, 'w', encoding="utf-8-sig") as f: 761 | f.write(','.join(export_key_headers)+"\n") 762 | for key in keys: 763 | row = [] 764 | for col in export_key_headers: 765 | if col in key: 766 | row.append("\"" + str(key[col]) + "\"") 767 | else: 768 | row.append("") 769 | f.write(','.join(row)+"\n") 770 | 771 | print(f"Exported to {filename}") 772 | 773 | 774 | def choose_games(humble_session,choice_month_name,identifier,chosen): 775 | for choice in chosen: 776 | display_name = choice["display_item_machine_name"] 777 | if "tpkds" not in choice: 778 | webbrowser.open(f"{HUMBLE_SUB_PAGE}{choice_month_name}/{display_name}") 779 | else: 780 | payload = { 781 | "gamekey":choice["tpkds"][0]["gamekey"], 782 | "parent_identifier":identifier, 783 | "chosen_identifiers[]":display_name, 784 | "is_multikey_and_from_choice_modal":"false" 785 | } 786 | status,res = perform_post(driver,HUMBLE_CHOOSE_CONTENT,payload) 787 | if not ("success" in res or not res["success"]): 788 | print("Error choosing " + choice["title"]) 789 | print(res) 790 | else: 791 | print("Chose game " + choice["title"]) 792 | 793 | 794 | def humble_chooser_mode(humble_session,order_details): 795 | try_redeem_keys = [] 796 | months = get_choices(humble_session,order_details) 797 | count = 0 798 | first = True 799 | for month in months: 800 | redeem_all = None 801 | if(first): 802 | redeem_keys = prompt_yes_no("Would you like to auto-redeem these keys after? (Will require Steam login)") 803 | first = False 804 | 805 | ready = False 806 | while not ready: 807 | cls() 808 | if month["choice_data"]["usesChoices"]: 809 | remaining = month["choices_remaining"] 810 | print() 811 | print(month["product"]["human_name"]) 812 | print(f"Choices remaining: {remaining}") 813 | else: 814 | remaining = len(month["available_choices"]) 815 | print("Available Games:\n") 816 | choices = month["available_choices"] 817 | for idx,choice in enumerate(choices): 818 | title = choice["title"] 819 | rating_text = "" 820 | if("review_text" in choice["user_rating"] and "steam_percent|decimal" in choice["user_rating"]): 821 | rating = choice["user_rating"]["review_text"].replace('_',' ') 822 | percentage = str(int(choice["user_rating"]["steam_percent|decimal"]*100)) + "%" 823 | rating_text = f" - {rating}({percentage})" 824 | exception = "" 825 | if "tpkds" not in choice: 826 | # These are weird cases that should be handled by Humble. 827 | exception = " (Must be redeemed through Humble directly)" 828 | print(f"{idx+1}. {title}{rating_text}{exception}") 829 | if(redeem_all == None and remaining == len(choices)): 830 | redeem_all = prompt_yes_no("Would you like to redeem all?") 831 | else: 832 | redeem_all = False 833 | 834 | if(redeem_all): 835 | user_input = [str(i+1) for i in range(0,len(choices))] 836 | else: 837 | if(redeem_keys): 838 | auto_redeem_note = "(We'll auto-redeem any keys activated via the webpage if you continue after!)" 839 | else: 840 | auto_redeem_note = "" 841 | print("\nOPTIONS:") 842 | print("To choose games, list the indexes separated by commas (e.g. '1' or '1,2,3')") 843 | print(f"Or type just 'link' to go to the webpage for this month {auto_redeem_note}") 844 | print("Or just press Enter to move on.") 845 | 846 | user_input = [uinput.strip() for uinput in input().split(',') if uinput.strip() != ""] 847 | 848 | if(len(user_input) == 0): 849 | ready = True 850 | elif(user_input[0].lower() == 'link'): 851 | webbrowser.open(HUMBLE_SUB_PAGE + month["product"]["choice_url"]) 852 | if redeem_keys: 853 | # May have redeemed keys on the webpage. 854 | try_redeem_keys.append(month["gamekey"]) 855 | else: 856 | invalid_option = lambda option: ( 857 | not option.isnumeric() 858 | or option == "0" 859 | or int(option) > len(choices) 860 | ) 861 | invalid = [option for option in user_input if invalid_option(option)] 862 | 863 | if(len(invalid) > 0): 864 | print("Error interpreting options: " + ','.join(invalid)) 865 | time.sleep(2) 866 | else: 867 | user_input = set(int(opt) for opt in user_input) # Uniques 868 | chosen = [choice for idx,choice in enumerate(choices) if idx+1 in user_input] 869 | # This weird enumeration is to keep it in original display order 870 | 871 | if len(chosen) > remaining: 872 | print(f"Too many games chosen, you have only {remaining} choices left") 873 | time.sleep(2) 874 | else: 875 | print("\nGames selected:") 876 | for choice in chosen: 877 | print(choice["title"]) 878 | confirmed = prompt_yes_no("Please type 'y' to confirm your selection") 879 | if confirmed: 880 | choice_month_name = month["product"]["choice_url"] 881 | identifier = month["parent_identifier"] 882 | choose_games(humble_session,choice_month_name,identifier,chosen) 883 | if redeem_keys: 884 | try_redeem_keys.append(month["gamekey"]) 885 | ready = True 886 | if(first): 887 | print("No Humble Choices need choosing! Look at you all up-to-date!") 888 | else: 889 | print("No more unchosen Humble Choices") 890 | if(redeem_keys and len(try_redeem_keys) > 0): 891 | print("Redeeming keys now!") 892 | updated_monthlies = humble_session.execute_async_script(getHumbleOrders.replace('%optional%',json.dumps(try_redeem_keys))) 893 | chosen_keys = list(find_dict_keys(updated_monthlies,"steam_app_id",True)) 894 | redeem_steam_keys(humble_session,chosen_keys) 895 | 896 | def cls(): 897 | os.system('cls' if os.name=='nt' else 'clear') 898 | print_main_header() 899 | 900 | def print_main_header(): 901 | print("-=FailSpy's Humble Bundle Helper!=-") 902 | print("--------------------------------------") 903 | 904 | if __name__=="__main__": 905 | # Create a consistent session for Humble API use 906 | driver = get_headless_driver() 907 | humble_login(driver) 908 | print("Successfully signed in on Humble.") 909 | 910 | print(f"Getting order details, please wait") 911 | 912 | order_details = driver.execute_async_script(getHumbleOrders.replace('%optional%','')) 913 | 914 | desired_mode = prompt_mode(order_details,driver) 915 | if(desired_mode == "2"): 916 | export_mode(driver,order_details) 917 | sys.exit() 918 | if(desired_mode == "3"): 919 | humble_chooser_mode(driver,order_details) 920 | sys.exit() 921 | 922 | # Auto-Redeem mode 923 | cls() 924 | unrevealed_keys = [] 925 | revealed_keys = [] 926 | steam_keys = list(find_dict_keys(order_details,"steam_app_id",True)) 927 | 928 | filters = ["errored.csv", "already_owned.csv", "redeemed.csv"] 929 | original_length = len(steam_keys) 930 | for filter_file in filters: 931 | try: 932 | with open(filter_file, "r") as f: 933 | keycols = f.read() 934 | filtered_keys = [keycol.strip() for keycol in keycols.replace("\n", ",").split(",")] 935 | steam_keys = [key for key in steam_keys if key.get("redeemed_key_val",False) not in filtered_keys] 936 | except FileNotFoundError: 937 | pass 938 | if len(steam_keys) != original_length: 939 | print("Filtered {} keys from previous runs".format(original_length - len(steam_keys))) 940 | 941 | for key in steam_keys: 942 | if "redeemed_key_val" in key: 943 | revealed_keys.append(key) 944 | else: 945 | # Has not been revealed via Humble yet 946 | unrevealed_keys.append(key) 947 | 948 | print( 949 | f"{len(steam_keys)} Steam keys total -- {len(revealed_keys)} revealed, {len(unrevealed_keys)} unrevealed" 950 | ) 951 | 952 | will_reveal_keys = prompt_yes_no("Would you like to redeem on Humble as-yet un-revealed Steam keys?" 953 | " (Revealing keys removes your ability to generate gift links for them)") 954 | if will_reveal_keys: 955 | try_already_revealed = prompt_yes_no("Would you like to attempt redeeming already-revealed keys as well?") 956 | # User has chosen to either redeem all keys or just the 'unrevealed' ones. 957 | redeem_steam_keys(driver, steam_keys if try_already_revealed else unrevealed_keys) 958 | else: 959 | # User has excluded unrevealed keys. 960 | redeem_steam_keys(driver, revealed_keys) 961 | 962 | # Cleanup 963 | for f in files: 964 | files[f].close() 965 | --------------------------------------------------------------------------------