""" Finds good deals for resale from TTC and prints them to an HTML page. - Uses proxies, if provided in a local file named "proxies.txt" in the format "http://123.123.123.123:123" per line. - Only checks a given list of items, if provided in a local file named "items.txt" in their search-results page URL format per line. """ import signal import os from traceback import format_exc from datetime import datetime from random import shuffle, uniform from time import sleep from math import ceil from statistics import mean from lxml import html import requests DEBUG = True # Let user force quit, if needed. def ForceQuit(signum=None, frame=None, msg='', graceful=True): global CRAWLER, DEBUG DEBUG and print(f'\n\n{GetTimestamp()} - Quitting...\n') try: if graceful and not CRAWLER.tracker.attemptedQuit: CRAWLER.options.attemptedQuit += 1 else: raise Exception() except Exception: os._exit(1) def GetTimestamp(file=False): """ Return a formatted timestamp. """ if file: d = str(datetime.now().strftime(f'%m_%d_%y %H_%M_%S')) else: d = str(datetime.now().strftime(f'%H:%M:%S')) return d class Options(): """ Handle all global options and trackers. """ def __init__(self, crawler): i = crawler.input self.attemptedQuit = False itemized = crawler.links.itemized # Default values. self.age = 60 # Itemized list only. self.pages = 5 self.risk = 3 self.profit = 5000 self.buy = 0 self.minUnits = 1 self.maxUnits = 200 self.exclude = [] self.require = [] # Applies to either scan. self.accuracy = 3 self.interval = 5 # Use itemized list or not. if itemized: r = i(f'Scan {len(crawler.links)} listed items [y]: ') if r and r != 'y': crawler.links.itemized = False # Some options are only relevant for an itemized links list, and others only for a global search. if crawler.links.itemized: try: self.age = int(i('Maximum age of listing in minutes [60]: ')) except Exception: pass else: try: self.pages = int(i('Search result pages to scan every round [5]: ')) except Exception: pass try: self.risk = int(i('Maximum Price / Profit risk ratio [3]: ')) except Exception: pass try: self.profit = int(i('Minimum profit-category in gold [5000]: ')) except Exception: pass try: self.buy = int(i('Maximum price per listing: ')) except Exception: pass try: self.minUnits = int(i('Minimum amount of units per listing: ')) except Exception: pass try: self.maxUnits = int(i('Maximum amount of units per listing [200]: ')) except Exception: pass self.exclude = i('Exclude all substrings from results (Example: key, writ): ') self.exclude = [x.strip().lower() for x in self.exclude.split(',') if x.strip()] self.require = i('Require any of substrings in results (Example: of power, necro): ') self.require = [x.strip().lower() for x in self.require.split(',') if x.strip()] # Display further options. try: self.accuracy = int(i('Item price-estimation accuracy by search pages [3]: ')) except Exception: pass try: self.interval = int(i('Delay between each round in minutes [5]: ')) except Exception: pass class Proxies(): """ Load proxies from file and handle proxy rotation. """ def __init__(self, crawler): self.crawler = crawler self.list = [] # Empty. Defaults to no proxy. self.index = 0 # Current proxy index. try: with open('proxies.txt') as f: # Not empty. Starts as a URL. self.list = [line.strip() for line in f if line.strip().startswith('http')] # Not empty. if not self.list: raise Exception() # Shuffle for when script is restarted often. shuffle(self.list) except Exception: x = input('Failed to load proxies from file! Continue without a proxy? [n]: ') if not x: ForceQuit(graceful=False) def GetProxy(self): """" Return the next proxy, or '' for no proxy used - but then sleep() too. """ if not self.list: # Insert a delay without proxies. sleep(10) return '' try: p = self.list[self.index] except Exception: # Start over. self.index = 0 p = self.list[self.index] finally: proxy = p self.index += 1 # Crawler tracking. self.crawler.tracker.proxyFailed += 1 self.crawler.proxy = proxy return proxy class Links(): """ Either load items to scan from file, or use generic recent search results. """ searchURL = ('https://us.tamrieltradecentre.com/pc/Trade/SearchResult?ItemID=&SearchType=Sell&ItemNamePattern=' '&ItemCategory1ID=&ItemCategory2ID=&ItemCategory3ID=&ItemTraitID=&ItemQualityID=&IsChampionPoint=false' '&LevelMin=&LevelMax=&MasterWritVoucherMin=&MasterWritVoucherMax=&AmountMin=&AmountMax=&PriceMin=&PriceMax=') def __init__(self, crawler): self.itemized = False # Item list has loaded. self.crawler = crawler # Set finally after Options() finished. self.list = [] try: with open('items.txt') as f: # Not empty. Starts as a URL. self.list = [line.strip() for line in f if line.strip().startswith('http')] # Empty. if not self.list: raise Exception() self.itemized = True except Exception: x = input('Failed to load items from file! Continue with a general scan? [y]: ') if x and x != 'y': ForceQuit(graceful=False) def __iter__(self): """ Yield all links. """ for i in self.list: yield i def __len__(self): """ Length of links list. """ return len(self.list) def UpdateList(self): """ Override the links list according to the user choice. """ if not self.itemized: self.list = [self.searchURL] # Mimic website url pattern. for i in range(2, self.crawler.options.pages + 1): self.list.append(f'{self.searchURL}&page={i}') class Tracker(): """ Track all crawler activities. """ def __init__(self): self.attemptedQuit = 0 self.proxyFailed = 0 self.matches = 0 # Successful listing matches. self.listings = 0 # All listings scanned through. class Item(): """ A TTC item with all its data. id name level str champion str - 'true' or 'false' quality trait Optional. vouchers Optional. For writs. trader 'Community' for guild trader, otherwise @player. location guild ppp int units int buy int - Total price of listing. lastSeen int - Last seen in minutes (Seen! not listed.) values [int, ...] - Most recent listings prices. sell int - Estimated worth. sellppp int - Estimated worth per unit. risk float - Higher number means higher risk of investment. profit int - Estimated profit made by reselling the item. limited bool - Has under 10 listings available. recentSales int - How many recent sales of the item were made. digitCategory int - How many digits are in the estimated sale-value of the item. profitBracket int - The nearest profit bracket to the estimated profit-value of the item. indexAll int - All listings counted. index int - Only successful matches counted. """ IdUrl = 'https://us.tamrieltradecentre.com/api/pc/Trade/GetItemAutoComplete?term=' sales = { 0: 'NOPE', 5000: 'SALE', 10000: 'GOOD SALE', 20000: 'SPICY SALE', 30000: 'HOT SALE', 50000: 'FLAMING SALE', } traits = { '': '', # Traits are optional. 'powered': 0, 'charged': 1, 'precise': 2, 'infused': 3, 'defending': 4, 'training': 5, 'sharpened': 6, 'decisive': 7, 'sturdy': 8, 'impenetrable': 9, 'reinforced': 10, 'well fitted': 11, 'invigorating': 12, 'divines': 13, 'nirnhoned': 14, 'intricate': 15, 'ornate': 16, 'arcane': 17, 'healthy': 18, 'robust': 19, 'special': 20, 'bloodthirsty': 21, 'harmony': 22, 'protective': 23, 'swift': 24, 'triune': 25, } qualities = { '': '', 'any quality': '', 'normal': 0, 'fine': 1, 'superior': 2, 'epic': 3, 'legendary': 4, } profit_categories = { # Number of digits in price. No fractions in the game. 1: 0, 2: 0, 3: 0, # Hundreds. 4: 0, # Thousands. 5: 0, 6: 0, 7: 0, # Millions. 8: 0, 9: 0, 10: 0, } def __init__(self, e, crawler): """ Use the Element() e to extract the item data. """ self.crawler = crawler self.time = GetTimestamp() self.indexAll = crawler.tracker.listings cols = e.xpath('td') c = cols[0] path = 'div[1]/text()' self.name = c.xpath(f'string({path})').strip() self.SetId() self.SetChamp(c) self.SetLevel(c) self.SetQuality(c) path = 'img/@data-trait' self.trait = c.xpath(f'string({path})').strip() self.SetVouchers(c) c = cols[1] # "Community" or user name. path = 'div/text()' self.trader = c.xpath(f'string({path})').strip() c = cols[2] self.SetLocation(c) c = cols[3] self.SetPrice(c) c = cols[4] path = './@data-mins-elapsed' self.lastSeen = int(float(c.xpath(f'string({path})').replace(',', ''))) # Assigned later. self.values = [] self.sell = 0 self.sellppp = 0 self.index = 0 self.risk = 0 self.profit = 0 self.digitCategory = 0 self.profitBracket = 0 self.limited = False self.recentSales = 0 def SetId(self, retry=False): """ Retry a failed connection until success, and set the item's id. """ c = self.crawler name = self.name link = self.IdUrl + name if retry: # New proxy. Handles tracking. proxy = c.proxies.GetProxy() else: # Use last proxy. proxy = c.proxy try: r = requests.get(link, headers={'https': proxy}) except Exception: raise if r.status_code != requests.codes.ok: self.SetId(True) return try: j = r.json() self.id = int(j[0]['ItemID']) except Exception: # Any unexpected error. self.SetId(True) return def SetChamp(self, c): try: path = 'div[3]/img/@src' champion = c.xpath(f'string({path})').strip() if not champion: raise Exception() except Exception: path = 'div[2]/img/@src' champion = c.xpath(f'string({path})').strip() # /Content/icons/nonvet.png or /Content/icons/championPoint.png if 'championPoint' in champion: self.champion = 'true' else: self.champion = 'false' def SetLevel(self, c): try: path = 'div[3]' level = c.xpath(f'string({path})').split()[-1] # Verify value. int(level) except Exception: path = 'div[2]' level = c.xpath(f'string({path})').split()[-1] self.level = level def SetQuality(self, c): path = 'img/@class' parts = c.xpath(f'string({path})').split() part = [x for x in parts if 'quality' in x][0] self.quality = part.split('-')[-1] def SetVouchers(self, c): try: path = 'div[2]/text()' v = c.xpath(f'string({path})') p = v.split()[1] self.vouchers = int(p) except Exception: # Not a writ. self.vouchers = '' pass def SetLocation(self, c): try: self.location = c.xpath(f'string(div[1]/text())').strip() self.guild = c.xpath(f'string(div[2]/text())').strip() except Exception: # No details for some private user trades. self.location = '' self.guild = '' def SetPrice(self, c): parts = c.xpath(f'string()').split() self.ppp = int(float(parts[0].replace(',', ''))) self.units = int(float(parts[2].replace(',', ''))) self.buy = int(float(parts[4].replace(',', ''))) def GetSale(self, n): return self.sales[n] def GetTrait(self, s): return str(self.traits[s.lower()]) def GetQuality(self, s): return str(self.qualities[s.lower()]) def SetValue(self): """ As part of Evaluate(), calculates the item's value per unit. Method: p = [15000, 15000, 14500, 22500, 33000, 9999, 27000, 35500, 150000, 200000] 1. Count how many digits are in each number: Singles, Doubles, Hundreds, Thousands, Tens of, Hundreds of, Millions. (8 Tens of, 2 Hundreds of.) 2. Select the most used digits state. (Tens of.) [15000, 15000, 14500, 22500, 33000, 9999, 27000, 35500] 3. Round all up or down (by nearness to middle) to one digit under category. [15000, 15000, 15000, 23000, 33000, 10000, 27000, 36000] 4. Get the mean(). (21750) 5. Get the nearest sale prices below and over the mean. (15000, 33000) Return all 3 last results rounded. ("22,000 [15,000, 33,000]") """ categories = self.profit_categories.copy() # Count the digits for every value, to find the most common. for v in self.values: categories[len(str(v))] += 1 # Get the biggest one. If more than one, get the bigger category. self.digitCategory = max(reversed(sorted(categories.keys())), key=(lambda k: categories[k])) # Keep only the values from that category. kept = [] for v in self.values: cat = len(str(v)) if cat == self.digitCategory: # Round to two digits under category. (1,553 -> 1,550) r = round(v, -self.digitCategory + 3) kept.append(r) # Add values from another category if it has a meaningful amount of listings. for v in self.values: cat = len(str(v)) # Skip main category. if cat == self.digitCategory: continue # NOTE Hard to say what number makes sense. Ratios don't make sense in any format. if categories[cat] >= 10: # Round to two digits under category. r = round(v, -self.digitCategory + 3) kept.append(r) # Statistical mean(). v = mean(kept) # No fractions. v = int(v) # Round to two digits under category. v = round(v, -int(self.digitCategory) + 3) self.sellppp = v self.sell = self.sellppp * self.units def Evaluate(self): """ Estimate the item's value from the values of further recent listings. Return the item if it passes all the filters, or None. """ opts = self.crawler.options self.SetValue() # Find my potential profit. Stacks calculate selling the whole stack. # Example: 120g * 37 - 35g * 37 = 4440 - 1295 = 3,145g EARNINGS self.profit = self.sell - self.buy # Round to two digits under category. self.profit = round(self.profit, -int(self.category) + 3) # Find nearest sale category value. # Example: profit = 3,145g matches category [5000]. 1,232g would match [0]. self.profitBracket = min(list(self.sales), key=lambda x: abs(x - self.profit)) # For the same profit, the smaller the purchase the better. # If buy and sell values are equal, then there's no assumed risk. NOTE Or is there? # Example: buy 1,000g / sell 2,000g = 0.5 risk if self.profit: self.risk = self.buy / self.profit # The advanced cost is too high for the potential profit. # Example: BUY 100k, SELL 105k, PROFIT 5k. risk = 100 / 5 = 20 # NOTE add < 0 case? if opts.risk and self.risk > opts.risk: return # Limited supply items don't even have 10 results in TTC. if len(self.values) < 10: self.limited = True return self def CompareItems(self, item): """ Returns True if two items are estimated to be the same listing. """ return (self.location == item.location and self.guild == item.guild and self.ppp == item.ppp and self.units == item.units) class Crawler(): """ Handles crawling webpages, one at a time (not async.) - Load all other classes. - Print results as a webpage file. """ # The format of the html page where all the results are saved, live. html = ( "\n" "\n" "
\n" " \n" " \n" "| - | \n" "# | \n" "Units | \n" "Name | \n" "Quality | \n" "Trait | \n" "Vouchers | \n" "Location | \n" "Guild | \n" "Total Price | \n" "Piece Price | \n" "Sell Value | \n" "Piece Value | \n" "Sale Category | \n" "Profit | \n" "Recent Sales | \n" "All Values | \n" "Limited Supply | \n" "|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {0} | \n" "{1} | \n" " | {2} | \n" "{3} | \n" "{4} | \n" "{5} | \n" "{6} | \n" "{7} | \n" "{8} | \n" "{9} | \n" "{10} | \n" "{11} | \n" "{12} | \n" "{13} | \n" "{14} | \n" "{15} | \n" "{16} | \n" "{17} | \n" "