├── 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 |
--------------------------------------------------------------------------------