-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathpucauto.py
executable file
·417 lines (316 loc) · 13.1 KB
/
pucauto.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
#!/usr/bin/env python
from __future__ import print_function
import json
import time
import six
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
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
def log(s):
"""Prints s with the date/time like Jul 16 09:15:48 PM CDT Hello world"""
print(u"{} {}".format(time.strftime("%b %d %I:%M:%S %p %Z"), s))
def print_pucauto():
"""Print logo and version number."""
print("""
_______ __ __ _______ _______ __ __ _______ _______
| || | | || || _ || | | || || |
| _ || | | || || |_| || | | ||_ _|| _ |
| |_| || |_| || || || |_| | | | | | | |
| ___|| || _|| || | | | | |_| |
| | | || |_ | _ || | | | | |
|___| |_______||_______||__| |__||_______| |___| |_______|
pucauto.com v0.4.9
github.com/tomreece/pucauto
@pucautobot on Twitter
""")
def wait_for_load():
"""Wait for PucaTrade's loading spinner to dissappear."""
time.sleep(1)
while True:
try:
loading_spinner = DRIVER.find_element_by_id("fancybox-loading")
except Exception:
break
def log_in():
"""Navigate to pucatrade.com and log in using credentials from CONFIG."""
DRIVER.get("http://www.pucatrade.com")
home_login_div = DRIVER.find_element_by_id("home-login")
home_login_div.find_element_by_id("login").send_keys(CONFIG["username"])
home_login_div.find_element_by_id("password").send_keys(CONFIG["password"])
home_login_div.find_element_by_class_name("btn-primary").click()
def goto_trades():
"""Go to the /trades page."""
DRIVER.get("https://pucatrade.com/trades")
def turn_on_auto_matching():
"""Click the toggle on the /trades page to turn on auto matching."""
DRIVER.find_element_by_css_selector("label.niceToggle").click()
def sort_by_member_points():
"""Click the Member Points table header to sort by member points (desc)."""
DRIVER.find_element_by_css_selector("th[title='user_points']").click()
def check_runtime():
"""Return True if the main execution loop should continue.
Selenium and Firefox eat up more and more memory after long periods of
running so this will stop Pucauto after a certain amount of time. If Pucauto
was started with the startup.sh script it will automatically restart itself
again. I typically run my instance for 2 hours between restarts on my 2GB
RAM cloud server.
"""
hours_to_run = CONFIG.get("hours_to_run")
if hours_to_run:
return (datetime.now() - START_TIME).total_seconds() / 60 / 60 < hours_to_run
else:
return True
def should_check_add_ons():
"""Return True if we should 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
else:
return True
def send_card(card, add_on=False):
"""Send a card.
Args:
card - A dictionary with href, name, and value keys
add_on - True if this card is an add on, False if it's part of a bundle
Returns True if the card was sent, False otherwise.
"""
if CONFIG.get("debug"):
log(u" DEBUG: Skipping send of '{}'".format(card["name"]))
return False
# Go to the /trades/sendcard/******* page first to secure the trade
DRIVER.get(card["href"])
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
log(u" Failed to send {}. Reason: {}".format(card["name"], reason))
return False
try:
# See if the the PucaShield insurance checkbox is checked or not
# This is determined by the user's PucaShield threshold value in their settings
is_insurance_selected = DRIVER.find_element_by_id("insurance").is_selected()
except NoSuchElementException:
# If the user doesn't have enough points to pay for PucaShield, then the
# checkbox will not be on the page. This prevents a crash in that case.
is_insurance_selected = False
confirm_url = card["href"].replace("sendcard", "confirm")
if is_insurance_selected:
confirm_url += "?ins=1"
# Then go to the /trades/confirm/******* page to confirm the trade
DRIVER.get(confirm_url)
if add_on:
log(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
log(u" Sent {} for {} PucaPoints!".format(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.
"""
DRIVER.get("https://pucatrade.com/trades/active")
try:
DRIVER.find_element_by_css_selector("div.dataTables_filter input").send_keys('Unshipped')
except NoSuchElementException:
return
# 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")
# 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)
for card in sorted_cards:
send_card(card, True)
def load_trade_list(partial=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
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
"""
old_scroll_y = 0
while True:
if partial:
try:
lowest_visible_points = int(
DRIVER.find_element_by_css_selector(".cards-show tbody tr:last-of-type td.points").text)
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
break
DRIVER.execute_script("window.scrollBy(0, 5000);")
wait_for_load()
new_scroll_y = DRIVER.execute_script("return window.scrollY;")
if new_scroll_y == old_scroll_y or new_scroll_y < old_scroll_y:
break
else:
old_scroll_y = new_scroll_y
def build_trades_dict(soup):
"""Iterate through the rows in the table on the /trades page and build up a
dictionary.
Args:
soup - A BeautifulSoup instance of the page DOM
Returns a dictionary like:
{
"1984581": {
"cards": [
{
"name": "Voice of Resurgence",
"value": 2350,
"href": https://pucatrade.com/trades/sendcard/38458273
},
{
"name": "Advent of the Wurm",
"value": 56,
"href": https://pucatrade.com/trades/sendcard/63524523
},
...
],
"name": "Philip J. Fry",
"points": 9001,
"value": 2406
},
...
}
"""
trades = {}
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/", "")
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
}
if trades.get(member_id):
# Seen this member before in another row so just add another card
trades[member_id]["cards"].append(card)
trades[member_id]["value"] += card_value
else:
# First time seeing this member so set up the data structure
trades[member_id] = {
"cards": [card],
"name": member_name,
"points": member_points,
"value": card_value
}
return trades
def find_highest_value_bundle(trades):
"""Find the highest value bundle in the trades dictionary.
Args:
trades - The result dictionary from build_trades_dict
Returns the highest value bundle, which is a tuple of the (k, v) from
trades.
"""
if len(trades) == 0:
return None
highest_value_bundle = max(six.iteritems(trades), key=lambda x: x[1]["value"])
if highest_value_bundle[1]["value"] >= CONFIG["min_value"]:
return highest_value_bundle
else:
return None
def complete_trades(highest_value_bundle):
"""Sort the cards by highest value first and then send them all.
Args:
highest_value_bundle - The result tuple from find_highest_value_bundle
"""
if not highest_value_bundle:
# No valid bundle was found, give up and restart the main loop
return
cards = highest_value_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"]
log(u"Found {} card(s) worth {} points to trade to {} who has {} points...".format(
len(sorted_cards), bundle_value, member_name, member_points))
success_count = 0
success_value = 0
for card in sorted_cards:
if send_card(card):
success_value += card["value"]
success_count += 1
log("Successfully sent {} out of {} cards worth {} points!".format(
success_count, len(sorted_cards), success_value))
def find_trades():
"""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():
log("Finding add-ons...")
find_and_send_add_ons()
LAST_ADD_ON_CHECK = datetime.now()
goto_trades()
wait_for_load()
load_trade_list(True)
soup = BeautifulSoup(DRIVER.page_source, "html.parser")
trades = build_trades_dict(soup)
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 __name__ == "__main__":
"""Start Pucauto."""
print_pucauto()
log("Logging in...")
log_in()
goto_trades()
wait_for_load()
# Explicit waits to be extra sure auto matching is on because if it's not
# then bad things happen, like Pucauto sending out cards you don't have.
# TODO: We could get smarter here and find a way to double check auto
# matching really is on, but I don't have a clever solution for it yet, so
# this is a band-aid safety measure.
time.sleep(5)
log("Turning on auto matching...")
turn_on_auto_matching()
time.sleep(5)
wait_for_load()
sort_by_member_points()
wait_for_load()
log("Finding trades...")
while check_runtime():
find_trades()
DRIVER.close()