diff --git a/config.example.json b/config.example.json index d368ead..5469f5c 100644 --- a/config.example.json +++ b/config.example.json @@ -1,8 +1,11 @@ { - "username": "youremailaddress@gmail.com", - "password": "yourpassword", - "min_value": 100, - "find_add_ons": true, - "minutes_between_add_ons_check": 5, - "debug": false + "debug" : false, + "username" : "youremailaddress@gmail.com", + "password" : "yourpassword", + "min_value" : 500, + "reload_trades_interval_s" : 60, + "reload_unshipped_interval_m" : 60, + "find_add_ons" : true, + "minutes_between_add_ons_check" : 5, + "hours_to_run" : 2 } diff --git a/pucauto.py b/pucauto.py index 007647a..82bfb3c 100755 --- a/pucauto.py +++ b/pucauto.py @@ -11,17 +11,14 @@ from datetime import datetime from bs4 import BeautifulSoup - with open("config.json") as config: CONFIG = json.load(config) - DRIVER = webdriver.Firefox() - START_TIME = datetime.now() LAST_ADD_ON_CHECK = START_TIME - +LAST_UNSHIPPED_CHECK = START_TIME def print_pucauto(): """Print logo and version number.""" @@ -40,6 +37,10 @@ def print_pucauto(): """) +def debug(str): + if CONFIG.get("debug"): + print("DEBUG: ", str) + def wait_for_load(): """Wait for PucaTrade's loading spinner to dissappear.""" @@ -97,14 +98,14 @@ def check_runtime(): return True -def should_check_add_ons(): - """Return True if we should check for add on trades.""" +def full_addon_check_due(interval_minutes): + """Return True if we should do a FULL check for add on trades.""" - minutes_between_add_ons_check = CONFIG.get("minutes_between_add_ons_check") - if minutes_between_add_ons_check: - return (datetime.now() - LAST_ADD_ON_CHECK).total_seconds() / 60 >= minutes_between_add_ons_check + global LAST_ADD_ON_CHECK + if CONFIG.get("find_add_ons"): + return (datetime.now() - LAST_ADD_ON_CHECK).total_seconds() / 60 >= interval_minutes else: - return True + return False def send_card(card, add_on=False): @@ -127,97 +128,87 @@ def send_card(card, add_on=False): try: DRIVER.find_element_by_id("confirm-trade-button") except Exception: - if not add_on: - reason = DRIVER.find_element_by_tag_name("h3").text - # Indented for readability because this is part of a bundle and there - # are header/footer messages - print(u" Failed to send {}. Reason: {}".format(card["name"], reason)) + # FAILED - output indented for readability w.r.t header/footer messages from elsewhere. + reason = DRIVER.find_element_by_tag_name("h3").text + print(u" Failed to send '{}'. Reason: {}".format(card["name"], reason)) return False # Then go to the /trades/confirm/******* page to confirm the trade DRIVER.get(card["href"].replace("sendcard", "confirm")) - if add_on: - print(u"Added on {} to an unshipped trade for {} PucaPoints!".format(card["name"], card["value"])) - else: - # Indented for readability because this is part of a bundle and there - # are header/footer messages - print(u" Sent {} for {} PucaPoints!".format(card["name"], card["value"])) + # SUCCESS - output indented for readability w.r.t header/footer messages from elsewhere. + print(u" {} '{}' for {} PucaPoints!".format(["Sent","Added"][add_on], card["name"], card["value"])) return True -def find_and_send_add_ons(): - """Build a list of members that have unshipped cards and then send them any - new cards that they may want. Card value is ignored because they are already - being shipped to. So it's fine to add any and all cards on. +def unshipped_reload_due(interval_minutes): + """Return True if we should reload unshipped traders list. + Presumably, we want to do this periodically, especially when we are physically shipping cards. + """ + + global LAST_UNSHIPPED_CHECK + return (datetime.now() - LAST_UNSHIPPED_CHECK).total_seconds() / 60 >= interval_minutes + + +def load_unshipped_traders(): + """Build and return a list of members for which we have unshipped cards. + Will be a dictionary from "trader id" : "trader profile name". """ + global LAST_UNSHIPPED_CHECK + + print("Loading unshipped traders...") + unshipped = dict() + DRIVER.get("https://pucatrade.com/trades/active") try: DRIVER.find_element_by_css_selector("div.dataTables_filter input").send_keys('Unshipped') except NoSuchElementException: - return + return unshipped # Wait a bit for the DOM to update after filtering time.sleep(5) - - soup = BeautifulSoup(DRIVER.page_source, "html.parser") - - unshipped = set() - for a in soup.find_all("a", class_="trader"): - unshipped.add(a.get("href")) - - goto_trades() - wait_for_load() - load_trade_list() soup = BeautifulSoup(DRIVER.page_source, "html.parser") + for trader in soup.find_all("a", class_="trader"): + debug(pprint.pformat(trader.contents)); + unshipped[trader["href"].replace("/profiles/show/", "")] = trader.contents[0].strip() - # Find all rows containing traders from the unshipped set we found earlier - rows = [r.find_parent("tr") for r in soup.find_all("a", href=lambda x: x and x in unshipped)] - - cards = [] - - for row in rows: - card_name = row.find("a", class_="cl").text - card_value = int(row.find("td", class_="value").text) - card_href = "https://pucatrade.com" + row.find("a", class_="fancybox-send").get("href") - card = { - "name": card_name, - "value": card_value, - "href": card_href - } - cards.append(card) - - # Sort by highest value to send those cards first - sorted_cards = sorted(cards, key=lambda k: k["value"], reverse=True) + #debug(u"Unshipped Traders List:\n{}".format(pprint.pformat(unshipped))) + if unshipped: + print(u"Unshipped Traders List:\n - {}" + .format("\n - ".join( sorted( map(lambda (k,v): v+" (id: "+k+")", unshipped.iteritems()) ) ))) - for card in sorted_cards: - send_card(card, True) + LAST_UNSHIPPED_CHECK = datetime.now() + return unshipped -def load_trade_list(partial=False): +def load_trade_list(full=False): """Scroll to the bottom of the page until we can't scroll any further. - PucaTrade's /trades page implements an infinite scroll table. Without this + PucaTrade's trades page implements an infinite scroll table. Without this function, we would only see a portion of the cards available for trade. Args: - partial - When True, only loads rows above min_value, thus speeding up - this function + full - When True, load ALL possible trades; otherwise, only load rows + above min_value, thus speeding up the search. """ old_scroll_y = 0 while True: - if partial: + debug("Scrolling trades table") + if not full: try: lowest_visible_points = int( DRIVER.find_element_by_css_selector(".cards-show tbody tr:last-of-type td.points").text) + debug("Lowest member points visible in trades table: {}".format(lowest_visible_points)) except: # We reached the bottom lowest_visible_points = -1 if lowest_visible_points < CONFIG["min_value"]: # Stop loading because there are no more members with points above min_value + debug("Curtail loading trades table; lowest: {} <= {} minimum trade.".format( + lowest_visible_points, CONFIG["min_value"])) break DRIVER.execute_script("window.scrollBy(0, 5000);") @@ -228,9 +219,10 @@ def load_trade_list(partial=False): break else: old_scroll_y = new_scroll_y + debug("Finished scrolling trades table") -def build_trades_dict(soup): +def build_trades_dict(soup, unshipped): """Iterate through the rows in the table on the /trades page and build up a dictionary. @@ -266,12 +258,12 @@ def build_trades_dict(soup): for row in soup.find_all("tr", id=lambda x: x and x.startswith("uc_")): member_points = int(row.find("td", class_="points").text) - if member_points < CONFIG["min_value"]: - # This member doesn't have enough points so move on to next row - continue member_link = row.find("td", class_="member").find("a", href=lambda x: x and x.startswith("/profiles")) - member_name = member_link.text.strip() member_id = member_link["href"].replace("/profiles/show/", "") + member_name = member_link.text.strip() + if (member_id not in unshipped and member_points < CONFIG["min_value"]) : + # This member isn't possible add on and doesn't have enough points so move on to next row + continue card_name = row.find("a", class_="cl").text card_value = int(row.find("td", class_="value").text) card_href = "https://pucatrade.com" + row.find("a", class_="fancybox-send").get("href") @@ -280,6 +272,8 @@ def build_trades_dict(soup): "value": card_value, "href": card_href } + if member_id in unshipped: + debug(u"found add-on card for '{}':\n{}".format(member_name,pprint.pformat(card))) if trades.get(member_id): # Seen this member before in another row so just add another card trades[member_id]["cards"].append(card) @@ -297,19 +291,21 @@ def build_trades_dict(soup): def find_highest_value_bundle(trades): - """Find the highest value bundle in the trades dictionary. + """Find the highest value bundle in the trades dictionary + with a trade total greater than our minimum threshold. Args: - trades - The result dictionary from build_trades_dict + trades - The result dictionary from build_trades_dict. Returns the highest value bundle, which is a tuple of the (k, v) from - trades. + trades, or None. """ if len(trades) == 0: return None highest_value_bundle = max(six.iteritems(trades), key=lambda x: x[1]["value"]) + #debug(u"Highest value bundle:\n{}".format(pprint.pformat(highest_value_bundle))) if highest_value_bundle[1]["value"] >= CONFIG["min_value"]: return highest_value_bundle @@ -317,65 +313,101 @@ def find_highest_value_bundle(trades): return None -def complete_trades(highest_value_bundle): +def complete_trades(bundle, add_on=False): """Sort the cards by highest value first and then send them all. Args: - highest_value_bundle - The result tuple from find_highest_value_bundle + bundle - tuple of trades for a single trader. + add_on - are these add-on trades for an unshipped bundle? + + return the number of cards successfully sent """ - if not highest_value_bundle: + if not bundle: # No valid bundle was found, give up and restart the main loop - return + return 0 - cards = highest_value_bundle[1]["cards"] + cards = bundle[1]["cards"] # Sort the cards by highest value to make the most valuable trades first. sorted_cards = sorted(cards, key=lambda k: k["value"], reverse=True) - member_name = highest_value_bundle[1]["name"] - member_points = highest_value_bundle[1]["points"] - bundle_value = highest_value_bundle[1]["value"] - print(u"Found {} card(s) worth {} points to trade to {} who has {} points...".format( - len(sorted_cards), bundle_value, member_name, member_points)) + member_name = bundle[1]["name"] + member_points = bundle[1]["points"] + bundle_value = bundle[1]["value"] + print(u"Found {}{} card(s) worth {} points to trade to {} who has {} points...".format( + len(sorted_cards), [""," additional"][add_on], + bundle_value, member_name, member_points)) success_count = 0 success_value = 0 for card in sorted_cards: - if send_card(card): + if send_card(card, add_on): success_value += card["value"] success_count += 1 - print("Successfully sent {} out of {} cards worth {} points!".format( - success_count, len(sorted_cards), success_value)) + print(u"Successfully {} {} out of {} cards worth {} points!".format( + ["sent","added"][add_on], success_count, len(sorted_cards), success_value)) + return success_count + + +def find_add_on_bundles(trades, unshipped): + """Return subset of 'trades' for which we are have unshipped cards + to those traders in the 'unshipped' dictionary. + """ + # interesting syntactic alternatives: http://stackoverflow.com/questions/2844516 + return {id: b for id, b in trades.iteritems() if id in unshipped} -def find_trades(): +def find_trades(unshipped, full_addon_check=False): """The special sauce. Read the docstrings for the individual functions to figure out how this works.""" - global LAST_ADD_ON_CHECK - - if CONFIG.get("find_add_ons") and should_check_add_ons(): - find_and_send_add_ons() - LAST_ADD_ON_CHECK = datetime.now() + debug("Looking for bundles...") goto_trades() wait_for_load() - load_trade_list(True) + + # Do a complete check only when we want to and when we have unshipped trades + if (full_addon_check and len(unshipped) > 0): + load_trade_list(True) + debug("Completed FULL serach for add ons; updating timer...") + global LAST_ADD_ON_CHECK + LAST_ADD_ON_CHECK = datetime.now() + else: + load_trade_list(False) + soup = BeautifulSoup(DRIVER.page_source, "html.parser") - trades = build_trades_dict(soup) + trades = build_trades_dict(soup, unshipped) + # Send higest value bundle, and track recipient in unshipped highest_value_bundle = find_highest_value_bundle(trades) - complete_trades(highest_value_bundle) - # Slow down to not hit PucaTrade refresh limit - time.sleep(5) + if highest_value_bundle: + if complete_trades(highest_value_bundle, highest_value_bundle[0] in unshipped) >= 1: + unshipped[highest_value_bundle[0]] = highest_value_bundle[1]["name"] + # remove from the trades dictionary regardless - we've already tried. + trades.pop(highest_value_bundle[0]) + # Send add-on bundles; this always happens, even if full_addon_check is false. + for bundle in find_add_on_bundles(trades, unshipped).iteritems(): + debug(u"Add-on bundle found:\n{}".format(pprint.pformat(bundle))) + complete_trades(bundle, True) if __name__ == "__main__": """Start Pucauto.""" print_pucauto() + + # sleep for refresh interval (seconds); default: 60; min: 5 + refresh_interval = max(5,CONFIG.get("reload_trades_interval_s") or 60) + # interval for reloading unshipped traders (minutes); default: 60; min 5 + unshipped_interval = max(5,CONFIG.get("reload_unshipped_interval_m") or 60) + # interval for chekcing for add-on trades (minutes); default: 20; min 0 + addon_check_interval = max(0.1,CONFIG.get("minutes_between_add_ons_check") or 20) + print("Logging in...") log_in() + unshipped = load_unshipped_traders() + + print("Loading trades page...") goto_trades() wait_for_load() @@ -392,7 +424,15 @@ def find_trades(): wait_for_load() sort_by_member_points() wait_for_load() - print("Finding trades...") + print("Finding trades ({} sec interval)...".format(refresh_interval)) while check_runtime(): - find_trades() + # reload unshipped traders periodically + if unshipped_reload_due(unshipped_interval): + unshipped = load_unshipped_traders() + # find and send trades, and perhaps add-ons + find_trades(unshipped, full_addon_check_due(addon_check_interval)) + # sleep for refresh interval (seconds) + time.sleep(refresh_interval) + DRIVER.close() +