cs2tracker 2.1.13__py3-none-any.whl → 2.1.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cs2tracker might be problematic. Click here for more details.

@@ -11,24 +11,35 @@ process.stderr.setEncoding("utf-8");
11
11
 
12
12
  const args = argv.slice(2);
13
13
  const processedInventoryPath = args[0];
14
- const importCases = args[1] === "True" ? true : false;
15
- const importStickerCapsules = args[2] === "True" ? true : false;
16
- const importStickers = args[3] === "True" ? true : false;
17
- const importOthers = args[4] === "True" ? true : false;
18
- const userName = args[5];
19
- const password = args[6];
20
- const twoFactorCode = args[7];
14
+ const importInventory = args[1] === "True" ? true : false;
15
+ const importStorageUnits = args[2] === "True" ? true : false;
16
+ const importCases = args[3] === "True" ? true : false;
17
+ const importStickerCapsules = args[4] === "True" ? true : false;
18
+ const importStickers = args[5] === "True" ? true : false;
19
+ const importOthers = args[6] === "True" ? true : false;
20
+ const userName = args[7];
21
+ const password = args[8];
22
+ const twoFactorCode = args[9];
21
23
 
22
24
  const paddedLog = (...args) => {
23
- console.log(" [+] ", ...args);
25
+ console.log(" [INFO] ", ...args);
24
26
  };
25
27
 
26
28
  const originalConsoleError = console.error;
27
29
  console.error = (...args) => {
28
- originalConsoleError(" [!] " + args.join(" "));
30
+ originalConsoleError(" [ERROR] " + args.join(" "));
29
31
  };
30
32
 
31
33
  (async () => {
34
+ const closeWithError = (message) => {
35
+ console.error(message);
36
+ console.error("This window will automatically close in 10 seconds.");
37
+ setTimeout(() => {
38
+ user.logOff();
39
+ process.exit(1);
40
+ }, 10000);
41
+ };
42
+
32
43
  let user = new SteamUser();
33
44
 
34
45
  paddedLog("Logging into Steam...");
@@ -39,25 +50,36 @@ console.error = (...args) => {
39
50
  twoFactorCode: twoFactorCode,
40
51
  });
41
52
 
42
- user.on("error", (err) => {
43
- console.error("Steam Error: " + err);
44
- user.logOff();
45
- process.exit(1);
53
+ const LOGIN_TIMEOUT_MS = 15000;
54
+ let loginTimeout = setTimeout(() => {
55
+ closeWithError(
56
+ "Login timed out. Please check your credentials and try again.",
57
+ );
58
+ }, LOGIN_TIMEOUT_MS);
59
+
60
+ user.on("steamGuard", (_domain, _callback, lastCodeWrong) => {
61
+ if (lastCodeWrong) {
62
+ closeWithError(
63
+ "The Steam Guard code you entered was incorrect. Please try again.",
64
+ );
65
+ }
46
66
  });
47
67
 
48
68
  user.on("loggedOn", (_details, _parental) => {
69
+ clearTimeout(loginTimeout);
49
70
  paddedLog("Logged into Steam.");
50
71
  user.gamesPlayed([730]);
72
+ paddedLog("Connecting to CS2 Game Coordinator...");
51
73
  });
52
74
 
53
- let cs2 = new CS2(user);
75
+ user.on("error", (err) => {
76
+ closeWithError(`Steam Error: ${err.message}`);
77
+ });
54
78
 
55
- paddedLog("Connecting to CS2 Game Coordinator...");
79
+ let cs2 = new CS2(user);
56
80
 
57
81
  cs2.on("error", (err) => {
58
- console.error("CS2 Error: " + err);
59
- user.logOff();
60
- process.exit(1);
82
+ closeWithError(`CS2 Error: ${err.message}`);
61
83
  });
62
84
 
63
85
  let nameConverter = new ItemNameConverter();
@@ -65,38 +87,77 @@ console.error = (...args) => {
65
87
 
66
88
  cs2.on("connectedToGC", async () => {
67
89
  paddedLog("Connected to CS2 Game Coordinator.");
68
- await processInventory();
90
+ let finalItemCounts = {};
91
+
92
+ if (importInventory) {
93
+ const inventoryItemCounts = await processInventory();
94
+ for (const [itemName, count] of Object.entries(inventoryItemCounts)) {
95
+ finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
96
+ }
97
+ }
98
+
99
+ if (importStorageUnits) {
100
+ const storageUnitItemCounts = await processStorageUnits();
101
+ for (const [itemName, count] of Object.entries(storageUnitItemCounts)) {
102
+ finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
103
+ }
104
+ }
105
+
106
+ paddedLog("Saving config...");
107
+ fs.writeFileSync(
108
+ processedInventoryPath,
109
+ JSON.stringify(finalItemCounts, null, 2),
110
+ );
111
+
112
+ paddedLog("Processing complete.");
113
+ paddedLog("This window will automatically close in 10 seconds.");
114
+ await new Promise((resolve) => setTimeout(resolve, 10000));
115
+ user.logOff();
116
+ process.exit(0);
69
117
  });
70
118
 
71
119
  async function processInventory() {
120
+ try {
121
+ // filter out items that have the casket_id property set from the inventory
122
+ // because these are items that should be contained in storage units
123
+ const prefilteredInventory = cs2.inventory.filter((item) => {
124
+ return !item.casket_id;
125
+ });
126
+
127
+ const convertedItems =
128
+ nameConverter.convertInventory(prefilteredInventory);
129
+ const filteredItems = filterItems(convertedItems);
130
+ const itemCounts = countItems(filteredItems);
131
+ paddedLog(`${filteredItems.length} items found in inventory`);
132
+ console.log(itemCounts);
133
+ return itemCounts;
134
+ } catch (err) {
135
+ console.error("An error occurred while processing the inventory:", err);
136
+ return {};
137
+ }
138
+ }
139
+
140
+ async function processStorageUnits() {
72
141
  let finalItemCounts = {};
73
142
  try {
74
143
  const storageUnitIds = getStorageUnitIds();
75
144
  for (const [unitIndex, unitId] of storageUnitIds.entries()) {
76
145
  const items = await getCasketContentsAsync(cs2, unitId);
77
- const convertedItems = nameConverter.convertInventory(items, false);
146
+ const convertedItems = nameConverter.convertInventory(items);
78
147
  const filteredItems = filterItems(convertedItems);
79
148
  const itemCounts = countItems(filteredItems);
80
149
  for (const [itemName, count] of Object.entries(itemCounts)) {
81
150
  finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
82
151
  }
83
152
  paddedLog(
84
- `${filteredItems.length} items found in storage unit: ${unitIndex}/${storageUnitIds.length}`,
153
+ `${filteredItems.length} items found in storage unit: ${unitIndex + 1}/${storageUnitIds.length}`,
85
154
  );
86
155
  console.log(itemCounts);
87
156
  }
88
- paddedLog("Saving config...");
89
- fs.writeFileSync(
90
- processedInventoryPath,
91
- JSON.stringify(finalItemCounts, null, 2),
92
- );
93
- paddedLog("Processing complete.");
94
- paddedLog("You may close this window now.");
157
+ return finalItemCounts;
95
158
  } catch (err) {
96
- console.error("An error occurred during processing:", err);
97
- } finally {
98
- user.logOff();
99
- process.exit(0);
159
+ console.error("An error occurred while processing storage units:", err);
160
+ return {};
100
161
  }
101
162
  }
102
163
 
@@ -122,6 +183,9 @@ console.error = (...args) => {
122
183
  function filterItems(items) {
123
184
  let filteredItems = [];
124
185
  items.forEach((item) => {
186
+ if (!item.item_tradable) {
187
+ return;
188
+ }
125
189
  if (
126
190
  (item.item_type === "case" && importCases) ||
127
191
  (item.item_type === "sticker capsule" && importStickerCapsules) ||
cs2tracker/logs.py ADDED
@@ -0,0 +1,143 @@
1
+ import csv
2
+ from datetime import datetime
3
+
4
+ from cs2tracker.config import get_config
5
+ from cs2tracker.constants import OUTPUT_FILE
6
+ from cs2tracker.scraper.parser import Parser
7
+ from cs2tracker.util.currency_conversion import convert, to_symbol
8
+
9
+ config = get_config()
10
+
11
+
12
+ class PriceLogs:
13
+ @classmethod
14
+ def _append_latest_calculation(cls, date, usd_totals):
15
+ """Append the first price calculation of the day."""
16
+ with open(OUTPUT_FILE, "a", newline="", encoding="utf-8") as price_logs:
17
+ price_logs_writer = csv.writer(price_logs)
18
+ price_entries_today = [f"{usd_total:.2f}$" for usd_total in usd_totals]
19
+ price_logs_writer.writerow([date] + price_entries_today)
20
+
21
+ @classmethod
22
+ def _replace_latest_calculation(cls, date, usd_totals):
23
+ """Replace the last calculation of today with the most recent one of today."""
24
+ with open(OUTPUT_FILE, "r+", newline="", encoding="utf-8") as price_logs:
25
+ price_logs_reader = csv.reader(price_logs)
26
+ rows = list(price_logs_reader)
27
+ rows_without_today = rows[:-1]
28
+ price_logs.seek(0)
29
+ price_logs.truncate()
30
+
31
+ price_logs_writer = csv.writer(price_logs)
32
+ price_logs_writer.writerows(rows_without_today)
33
+ price_entries_today = [f"{usd_total:.2f}$" for usd_total in usd_totals]
34
+ price_logs_writer.writerow([date] + price_entries_today)
35
+
36
+ @classmethod
37
+ def save(cls, usd_totals):
38
+ """
39
+ Save the current date and total prices in USD to a CSV file.
40
+
41
+ This will append a new entry to the output file if no entry has been made for
42
+ today.
43
+
44
+ :param usd_totals: The total prices in USD to save.
45
+ :raises FileNotFoundError: If the output file does not exist.
46
+ :raises IOError: If there is an error writing to the output file.
47
+ """
48
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as price_logs:
49
+ price_logs_reader = csv.reader(price_logs)
50
+ rows = list(price_logs_reader)
51
+ last_log_date = rows[-1][0] if rows else ""
52
+
53
+ today = datetime.now().strftime("%Y-%m-%d")
54
+ if last_log_date != today:
55
+ cls._append_latest_calculation(today, usd_totals)
56
+ else:
57
+ cls._replace_latest_calculation(today, usd_totals)
58
+
59
+ @classmethod
60
+ def read(cls, newest_first=False, with_symbols=False):
61
+ """
62
+ Parse the output file to extract dates, dollar prices, and the converted
63
+ currency prices. This data is used for drawing the plot of past prices.
64
+
65
+ :param newest_first: If True, the dates and totals will be returned in reverse
66
+ order
67
+ :param with_symbols: If True, the prices will be formatted with currency symbols
68
+ :return: A tuple containing dates and a dictionary of totals for each price
69
+ source.
70
+ :raises FileNotFoundError: If the output file does not exist.
71
+ :raises IOError: If there is an error reading the output file.
72
+ """
73
+ conversion_currency = config.conversion_currency
74
+ dates = []
75
+ totals = {
76
+ price_source: {"USD": [], conversion_currency: []} for price_source in Parser.SOURCES
77
+ }
78
+
79
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as price_logs:
80
+ price_logs_reader = csv.reader(price_logs)
81
+ for row in price_logs_reader:
82
+ date, *usd_totals = row
83
+ date = datetime.strptime(date, "%Y-%m-%d")
84
+
85
+ usd_totals = [float(price_usd.rstrip("$")) for price_usd in usd_totals]
86
+ converted_totals = [
87
+ convert(price_usd, "USD", conversion_currency) for price_usd in usd_totals
88
+ ]
89
+
90
+ dates.append(date)
91
+ for price_source_index, price_source in enumerate(Parser.SOURCES):
92
+ totals[price_source]["USD"].append(usd_totals[price_source_index])
93
+ totals[price_source][conversion_currency].append(
94
+ converted_totals[price_source_index]
95
+ )
96
+
97
+ if newest_first:
98
+ dates.reverse()
99
+ for price_source in Parser.SOURCES:
100
+ totals[price_source]["USD"].reverse()
101
+ totals[price_source][conversion_currency].reverse()
102
+
103
+ if with_symbols:
104
+ for price_source in Parser.SOURCES:
105
+ totals[price_source]["USD"] = [
106
+ f"${price:.2f}" for price in totals[price_source]["USD"]
107
+ ]
108
+ totals[price_source][conversion_currency] = [
109
+ f"{to_symbol(conversion_currency)}{price:.2f}"
110
+ for price in totals[price_source][conversion_currency]
111
+ ]
112
+
113
+ return dates, totals
114
+
115
+ @classmethod
116
+ def validate_file(cls, log_file_path):
117
+ # pylint: disable=expression-not-assigned
118
+ """
119
+ Ensures that the provided price log file has the right format. This should be
120
+ used before importing a price log file to ensure it is valid.
121
+
122
+ :param log_file_path: The path to the price log file to validate.
123
+ :return: True if the price log file is valid, False otherwise.
124
+ """
125
+ try:
126
+ with open(log_file_path, "r", encoding="utf-8") as price_logs:
127
+ price_logs_reader = csv.reader(price_logs)
128
+ for row in price_logs_reader:
129
+ date_str, *usd_totals = row
130
+ datetime.strptime(date_str, "%Y-%m-%d")
131
+ [float(price_usd.rstrip("$")) for price_usd in usd_totals]
132
+ except (FileNotFoundError, IOError, ValueError, TypeError):
133
+ return False
134
+ except Exception:
135
+ return False
136
+
137
+ return True
138
+
139
+ @classmethod
140
+ def empty(cls):
141
+ """Checks if the price history is empty and returns True if it is."""
142
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as price_logs:
143
+ return len(list(price_logs)) == 0
cs2tracker/main.py CHANGED
@@ -1,19 +1,11 @@
1
1
  import sys
2
- from subprocess import DEVNULL
3
2
 
4
3
  import urllib3
5
- from nodejs import npm
6
4
 
7
- from cs2tracker.app import Application
8
- from cs2tracker.constants import (
9
- AUTHOR_STRING,
10
- BANNER,
11
- INVENTORY_IMPORT_SCRIPT_DEPENDENCIES,
12
- OS,
13
- OSType,
14
- )
15
- from cs2tracker.scraper import Scraper
16
- from cs2tracker.util import get_console
5
+ from cs2tracker.app.app import Application
6
+ from cs2tracker.constants import AUTHOR_STRING, BANNER, OS, OSType
7
+ from cs2tracker.scraper.scraper import Scraper
8
+ from cs2tracker.util.padded_console import get_console
17
9
 
18
10
 
19
11
  def main():
@@ -38,13 +30,6 @@ def main():
38
30
  scraper = Scraper()
39
31
  scraper.scrape_prices()
40
32
  else:
41
- # Ensures that the necessary node modules are installed if a user wants
42
- # to import their steam inventory via the cs2tracker/data/get_inventory.js Node.js script.
43
- # This can be done in a daemon thread on application startup because it is not a very costly operation.
44
- npm.Popen(
45
- ["install"] + INVENTORY_IMPORT_SCRIPT_DEPENDENCIES, stdout=DEVNULL, stderr=DEVNULL
46
- )
47
-
48
33
  application = Application()
49
34
  application.run()
50
35
 
@@ -1,9 +0,0 @@
1
- from cs2tracker.scraper.background_task import ( # noqa: F401 # pylint:disable=unused-import
2
- BackgroundTask,
3
- )
4
- from cs2tracker.scraper.discord_notifier import ( # noqa: F401 # pylint:disable=unused-import
5
- DiscordNotifier,
6
- )
7
- from cs2tracker.scraper.scraper import ( # noqa: F401 # pylint:disable=unused-import
8
- Scraper,
9
- )
@@ -9,7 +9,7 @@ from cs2tracker.constants import (
9
9
  RUNNING_IN_EXE,
10
10
  OSType,
11
11
  )
12
- from cs2tracker.util import get_console
12
+ from cs2tracker.util.padded_console import get_console
13
13
 
14
14
  WIN_BACKGROUND_TASK_NAME = "CS2Tracker Daily Calculation"
15
15
  WIN_BACKGROUND_TASK_SCHEDULE = "DAILY"
@@ -1,13 +1,16 @@
1
1
  import requests
2
2
  from requests.exceptions import RequestException
3
3
 
4
- from cs2tracker.util import PriceLogs, get_console
4
+ from cs2tracker.config import get_config
5
+ from cs2tracker.logs import PriceLogs
6
+ from cs2tracker.util.padded_console import get_console
5
7
 
6
8
  DC_WEBHOOK_USERNAME = "CS2Tracker"
7
9
  DC_WEBHOOK_AVATAR_URL = "https://img.icons8.com/?size=100&id=uWQJp2tLXUH6&format=png&color=000000"
8
10
  DC_RECENT_HISTORY_LIMIT = 5
9
11
 
10
12
  console = get_console()
13
+ config = get_config()
11
14
 
12
15
 
13
16
  class DiscordNotifier:
@@ -19,42 +22,41 @@ class DiscordNotifier:
19
22
 
20
23
  :return: A list of embeds for the Discord message.
21
24
  """
22
- dates, usd_prices, eur_prices = PriceLogs.read()
23
- dates, usd_prices, eur_prices = reversed(dates), reversed(usd_prices), reversed(eur_prices)
25
+ dates, totals = PriceLogs.read(newest_first=True, with_symbols=True)
24
26
 
25
- date_history, usd_history, eur_history = [], [], []
26
- for date, usd_log, eur_log in zip(dates, usd_prices, eur_prices):
27
- if len(date_history) >= DC_RECENT_HISTORY_LIMIT:
28
- break
29
- date_history.append(date.strftime("%Y-%m-%d"))
30
- usd_history.append(f"${usd_log:.2f}")
31
- eur_history.append(f"€{eur_log:.2f}")
32
-
33
- date_history = "\n".join(date_history)
34
- usd_history = "\n".join(usd_history)
35
- eur_history = "\n".join(eur_history)
27
+ date_field = [
28
+ {
29
+ "name": "Date",
30
+ "value": "\n".join([date.strftime("%Y-%m-%d") for date in dates][:DC_RECENT_HISTORY_LIMIT]), # type: ignore
31
+ "inline": True,
32
+ },
33
+ ]
34
+ price_fields = [
35
+ {
36
+ "name": f"{price_source.name.title()} (USD | {config.conversion_currency})",
37
+ "value": "\n".join(
38
+ [
39
+ f"{usd_total} | {converted_total}"
40
+ for usd_total, converted_total in zip(
41
+ totals[price_source]["USD"][:DC_RECENT_HISTORY_LIMIT],
42
+ totals[price_source][config.conversion_currency][
43
+ :DC_RECENT_HISTORY_LIMIT
44
+ ],
45
+ )
46
+ ]
47
+ ),
48
+ "inline": True,
49
+ }
50
+ for price_source in totals
51
+ ][
52
+ :2
53
+ ] # Limit to the first two price sources because Discord can only display 3 fields per line (Date + 2 Price Sources)
36
54
 
37
55
  embeds = [
38
56
  {
39
- "title": "📊 Recent Price History",
57
+ "title": "📊 Recent Investment History",
40
58
  "color": 5814783,
41
- "fields": [
42
- {
43
- "name": "Date",
44
- "value": date_history,
45
- "inline": True,
46
- },
47
- {
48
- "name": "USD Total",
49
- "value": usd_history,
50
- "inline": True,
51
- },
52
- {
53
- "name": "EUR Total",
54
- "value": eur_history,
55
- "inline": True,
56
- },
57
- ],
59
+ "fields": date_field + price_fields,
58
60
  }
59
61
  ]
60
62
 
@@ -0,0 +1,189 @@
1
+ from abc import ABC, abstractmethod
2
+ from enum import Enum
3
+ from urllib.parse import unquote
4
+
5
+ from bs4 import BeautifulSoup
6
+ from bs4.element import Tag
7
+
8
+ from cs2tracker.config import get_config
9
+ from cs2tracker.constants import CAPSULE_PAGES
10
+ from cs2tracker.util.padded_console import get_console
11
+
12
+ config = get_config()
13
+ console = get_console()
14
+
15
+
16
+ class PriceSource(Enum):
17
+ STEAM = "steam"
18
+ BUFF163 = "buff163"
19
+ SKINPORT = "skinport"
20
+ YOUPIN898 = "youpin"
21
+ CSFLOAT = "csfloat"
22
+
23
+
24
+ class BaseParser(ABC):
25
+ @classmethod
26
+ @abstractmethod
27
+ def get_item_page_url(cls, item_href, source=PriceSource.STEAM) -> str:
28
+ """
29
+ Convert an href of a Steam Community Market item to a Parser-specific market
30
+ page URL.
31
+
32
+ :param item_href: The href of the item listing, typically ending with the item's
33
+ name.
34
+ :return: A URL string for the Parser market page of the item.
35
+ """
36
+
37
+ @classmethod
38
+ @abstractmethod
39
+ def parse_item_price(cls, item_page, item_href, source=PriceSource.STEAM) -> float:
40
+ """
41
+ Parse the price of an item from the given Parser market page and steamcommunity
42
+ item href.
43
+
44
+ :param item_page: The HTTP response object containing the item page content.
45
+ :param item_href: The href of the item listing to find the price for.
46
+ :return: The price of the item as a float.
47
+ :raises ValueError: If the item listing or price span cannot be found.
48
+ """
49
+
50
+
51
+ class SteamParser(BaseParser):
52
+ STEAM_MARKET_SEARCH_PAGE_BASE_URL = "https://steamcommunity.com/market/search?q={}"
53
+ PRICE_INFO = "Owned: {:<10} {} price: ${:<10} Total: ${:<10}"
54
+ NEEDS_TIMEOUT = True
55
+ SOURCES = [PriceSource.STEAM]
56
+
57
+ @classmethod
58
+ def get_item_page_url(cls, item_href, source=PriceSource.STEAM):
59
+ _ = source
60
+
61
+ # For higher efficiency we want to reuse the same page for sticker capsules (scraper uses caching)
62
+ # Therefore, if the provided item is a sticker capsule we return a search page defined in CAPSULE_PAGES
63
+ # where all of the sticker capsules of one section are listed
64
+ for section in config.sections():
65
+ if section in ("Skins", "Stickers", "Cases", "User Settings", "App Settings"):
66
+ continue
67
+ if any(item_href == option for option in config.options(section)):
68
+ return CAPSULE_PAGES[section]
69
+
70
+ url_encoded_name = item_href.split("/")[-1]
71
+ page_url = cls.STEAM_MARKET_SEARCH_PAGE_BASE_URL.format(url_encoded_name)
72
+
73
+ return page_url
74
+
75
+ @classmethod
76
+ def parse_item_price(cls, item_page, item_href, source=PriceSource.STEAM):
77
+ _ = source
78
+
79
+ item_soup = BeautifulSoup(item_page.content, "html.parser")
80
+ item_listing = item_soup.find("a", attrs={"href": f"{item_href}"})
81
+ if not isinstance(item_listing, Tag):
82
+ raise ValueError(f"Steam: Failed to find item listing for: {item_href}")
83
+
84
+ item_price_span = item_listing.find("span", attrs={"class": "normal_price"})
85
+ if not isinstance(item_price_span, Tag):
86
+ raise ValueError(f"Steam: Failed to find price span in item listing for: {item_href}")
87
+
88
+ price_str = item_price_span.text.split()[2]
89
+ price = float(price_str.replace("$", ""))
90
+
91
+ return price
92
+
93
+
94
+ class ClashParser(BaseParser):
95
+ CLASH_ITEM_API_BASE_URL = "https://inventory.clash.gg/api/GetItemPrice?id={}"
96
+ PRICE_INFO = "Owned: {:<10} {} price: ${:<10} Total: ${:<10}"
97
+ NEEDS_TIMEOUT = True
98
+ SOURCES = [PriceSource.STEAM]
99
+
100
+ @classmethod
101
+ def get_item_page_url(cls, item_href, source=PriceSource.STEAM):
102
+ _ = source
103
+
104
+ url_encoded_name = item_href.split("/")[-1]
105
+ page_url = cls.CLASH_ITEM_API_BASE_URL.format(url_encoded_name)
106
+
107
+ return page_url
108
+
109
+ @classmethod
110
+ def parse_item_price(cls, item_page, item_href, source=PriceSource.STEAM):
111
+ _, _ = item_href, source
112
+
113
+ data = item_page.json()
114
+ if data.get("success", "false") == "false":
115
+ raise ValueError(f"Clash: Response failed for: {item_href}")
116
+
117
+ price = data.get("average_price", None)
118
+ if not price:
119
+ raise ValueError(f"Clash: Failed to find item price for: {item_href}")
120
+
121
+ price = float(price)
122
+
123
+ return price
124
+
125
+
126
+ class CSGOTraderParser(BaseParser):
127
+ CSGOTRADER_PRICE_LIST = "https://prices.csgotrader.app/latest/{}.json"
128
+ PRICE_INFO = "Owned: {:<10} {:<10}: ${:<10} Total: ${:<10}"
129
+ NEEDS_TIMEOUT = False
130
+ SOURCES = [PriceSource.STEAM, PriceSource.BUFF163, PriceSource.YOUPIN898]
131
+
132
+ @classmethod
133
+ def get_item_page_url(cls, item_href, source=PriceSource.STEAM):
134
+ _ = item_href
135
+
136
+ page_url = cls.CSGOTRADER_PRICE_LIST.format(source.value)
137
+
138
+ return page_url
139
+
140
+ @classmethod
141
+ def parse_item_price(cls, item_page, item_href, source=PriceSource.STEAM):
142
+ # pylint: disable=too-many-branches
143
+ _ = source
144
+
145
+ price_list = item_page.json()
146
+
147
+ url_decoded_name = unquote(item_href.split("/")[-1])
148
+ if source in (PriceSource.BUFF163, PriceSource.SKINPORT):
149
+ url_decoded_name = url_decoded_name.replace("Holo-Foil", "Holo/Foil")
150
+
151
+ price_info = price_list.get(url_decoded_name, None)
152
+ if not price_info:
153
+ raise ValueError(f"CSGOTrader: Could not find item price info: {url_decoded_name}")
154
+
155
+ if source == PriceSource.STEAM:
156
+ for timestamp in ("last_24h", "last_7d", "last_30d", "last_90d"):
157
+ price = price_info.get(timestamp)
158
+ if price:
159
+ break
160
+ else:
161
+ raise ValueError(
162
+ f"CSGOTrader: Could not find steam price info for the past 3 months: {url_decoded_name}"
163
+ )
164
+ elif source == PriceSource.BUFF163:
165
+ price = price_info.get("starting_at")
166
+ if not price:
167
+ raise ValueError(f"CSGOTrader: Could not find buff163 listing: {url_decoded_name}")
168
+ price = price.get("price")
169
+ if not price:
170
+ raise ValueError(
171
+ f"CSGOTrader: Could not find recent buff163 price: {url_decoded_name}"
172
+ )
173
+ elif source == PriceSource.YOUPIN898:
174
+ price = price_info
175
+ if not price:
176
+ raise ValueError(
177
+ f"CSGOTrader: Could not find recent youpin898 price: {url_decoded_name}"
178
+ )
179
+ else:
180
+ price = price_info.get("starting_at")
181
+ if not price:
182
+ raise ValueError(f"CSGOTrader: Could not find skinport listing: {url_decoded_name}")
183
+
184
+ price = float(price)
185
+ return price
186
+
187
+
188
+ # Default parser used by the scraper
189
+ Parser = CSGOTraderParser