cs2tracker 2.1.9__py3-none-any.whl → 2.1.11__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.

@@ -0,0 +1,348 @@
1
+ import time
2
+ from datetime import datetime
3
+ from urllib.parse import unquote
4
+
5
+ from bs4 import BeautifulSoup
6
+ from bs4.element import Tag
7
+ from currency_converter import CurrencyConverter
8
+ from requests import RequestException, Session
9
+ from requests.adapters import HTTPAdapter, Retry
10
+ from tenacity import RetryError, retry, stop_after_attempt
11
+
12
+ from cs2tracker.constants import AUTHOR_STRING, BANNER, CAPSULE_INFO, CASE_HREFS
13
+ from cs2tracker.scraper.discord_notifier import DiscordNotifier
14
+ from cs2tracker.util import PaddedConsole, PriceLogs, ValidatedConfig
15
+
16
+ MAX_LINE_LEN = 72
17
+ SEPARATOR = "-"
18
+ PRICE_INFO = "Owned: {:<10} Steam market price: ${:<10} Total: ${:<10}\n"
19
+
20
+ HTTP_PROXY_URL = "http://{}:@smartproxy.crawlbase.com:8012"
21
+ HTTPS_PROXY_URL = "http://{}:@smartproxy.crawlbase.com:8012"
22
+
23
+ console = PaddedConsole()
24
+
25
+
26
+ class Scraper:
27
+ def __init__(self):
28
+ """Initialize the Scraper class."""
29
+ self.load_config()
30
+ self._start_session()
31
+
32
+ self.usd_total = 0
33
+ self.eur_total = 0
34
+
35
+ def load_config(self):
36
+ """Load the configuration file and validate its contents."""
37
+ self.config = ValidatedConfig()
38
+
39
+ def _start_session(self):
40
+ """Start a requests session with custom headers and retry logic."""
41
+ self.session = Session()
42
+ self.session.headers.update(
43
+ {
44
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"
45
+ }
46
+ )
47
+ retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504, 520])
48
+ self.session.mount("http://", HTTPAdapter(max_retries=retries))
49
+ self.session.mount("https://", HTTPAdapter(max_retries=retries))
50
+
51
+ def scrape_prices(self, update_sheet_callback=None):
52
+ """
53
+ Scrape prices for capsules and cases, calculate totals in USD and EUR, and
54
+ print/save the results.
55
+
56
+ :param update_sheet_callback: Optional callback function to update a tksheet
57
+ that is displayed in the GUI with the latest scraper price calculation.
58
+ """
59
+ if not self.config.valid:
60
+ console.print(
61
+ "[bold red][!] Invalid configuration. Please fix the config file before running."
62
+ )
63
+ return
64
+
65
+ capsule_usd_total = self._scrape_capsule_section_prices(update_sheet_callback)
66
+ case_usd_total = self._scrape_case_prices(update_sheet_callback)
67
+ custom_item_usd_total = self._scrape_custom_item_prices(update_sheet_callback)
68
+
69
+ self.usd_total += capsule_usd_total
70
+ self.usd_total += case_usd_total
71
+ self.usd_total += custom_item_usd_total
72
+ self.eur_total = CurrencyConverter().convert(self.usd_total, "USD", "EUR")
73
+
74
+ if update_sheet_callback:
75
+ update_sheet_callback(["", "", "", ""])
76
+ update_sheet_callback(
77
+ [
78
+ f"[{datetime.now().strftime('%Y-%m-%d')}] Total:",
79
+ f"${self.usd_total:.2f}",
80
+ f"€{self.eur_total:.2f}",
81
+ "",
82
+ ]
83
+ )
84
+
85
+ self._print_total()
86
+ PriceLogs.save(self.usd_total, self.eur_total)
87
+ self._send_discord_notification()
88
+
89
+ # Reset totals for next run
90
+ self.usd_total, self.eur_total = 0, 0
91
+
92
+ def _print_total(self):
93
+ """Print the total prices in USD and EUR, formatted with titles and
94
+ separators.
95
+ """
96
+ usd_title = "USD Total".center(MAX_LINE_LEN, SEPARATOR)
97
+ console.print(f"[bold green]{usd_title}")
98
+ console.print(f"${self.usd_total:.2f}")
99
+
100
+ eur_title = "EUR Total".center(MAX_LINE_LEN, SEPARATOR)
101
+ console.print(f"[bold green]{eur_title}")
102
+ console.print(f"€{self.eur_total:.2f}")
103
+
104
+ end_string = SEPARATOR * MAX_LINE_LEN
105
+ console.print(f"[bold green]{end_string}\n")
106
+
107
+ def _send_discord_notification(self):
108
+ """Send a message to a Discord webhook if notifications are enabled in the
109
+ config file and a webhook URL is provided.
110
+ """
111
+ discord_notifications = self.config.getboolean(
112
+ "App Settings", "discord_notifications", fallback=False
113
+ )
114
+ webhook_url = self.config.get("User Settings", "discord_webhook_url", fallback=None)
115
+
116
+ if discord_notifications and webhook_url:
117
+ DiscordNotifier.notify(webhook_url)
118
+
119
+ @retry(stop=stop_after_attempt(10))
120
+ def _get_page(self, url):
121
+ """
122
+ Get the page content from the given URL, using a proxy if configured. If the
123
+ request fails, it will retry up to 10 times.
124
+
125
+ :param url: The URL to fetch the page from.
126
+ :return: The HTTP response object containing the page content.
127
+ :raises RequestException: If the request fails.
128
+ :raises RetryError: If the retry limit is reached.
129
+ """
130
+ use_proxy = self.config.getboolean("App Settings", "use_proxy", fallback=False)
131
+ proxy_api_key = self.config.get("User Settings", "proxy_api_key", fallback=None)
132
+
133
+ if use_proxy and proxy_api_key:
134
+ page = self.session.get(
135
+ url=url,
136
+ proxies={
137
+ "http": HTTP_PROXY_URL.format(proxy_api_key),
138
+ "https": HTTPS_PROXY_URL.format(proxy_api_key),
139
+ },
140
+ verify=False,
141
+ )
142
+ else:
143
+ page = self.session.get(url)
144
+
145
+ if not page.ok or not page.content:
146
+ console.print(f"[bold red][!] Failed to load page ({page.status_code}). Retrying...\n")
147
+ raise RequestException(f"Failed to load page: {url}")
148
+
149
+ return page
150
+
151
+ def _parse_item_price(self, item_page, item_href):
152
+ """
153
+ Parse the price of an item from the given steamcommunity market page and item
154
+ href.
155
+
156
+ :param item_page: The HTTP response object containing the item page content.
157
+ :param item_href: The href of the item listing to find the price for.
158
+ :return: The price of the item as a float.
159
+ :raises ValueError: If the item listing or price span cannot be found.
160
+ """
161
+ item_soup = BeautifulSoup(item_page.content, "html.parser")
162
+ item_listing = item_soup.find("a", attrs={"href": f"{item_href}"})
163
+ if not isinstance(item_listing, Tag):
164
+ raise ValueError(f"Failed to find item listing: {item_href}")
165
+
166
+ item_price_span = item_listing.find("span", attrs={"class": "normal_price"})
167
+ if not isinstance(item_price_span, Tag):
168
+ raise ValueError(f"Failed to find price span in item listing: {item_href}")
169
+
170
+ price_str = item_price_span.text.split()[2]
171
+ price = float(price_str.replace("$", ""))
172
+
173
+ return price
174
+
175
+ def _scrape_capsule_prices(self, capsule_section, capsule_info, update_sheet_callback=None):
176
+ """
177
+ Scrape prices for a specific capsule section, printing the details to the
178
+ console.
179
+
180
+ :param capsule_section: The section name in the config for the capsule.
181
+ :param capsule_info: A dictionary containing information about the capsule page,
182
+ hrefs, and names.
183
+ :param update_sheet_callback: Optional callback function to update a tksheet
184
+ that is displayed in the GUI with the latest scraper price calculation.
185
+ """
186
+ capsule_title = capsule_section.center(MAX_LINE_LEN, SEPARATOR)
187
+ console.print(f"[bold magenta]{capsule_title}\n")
188
+
189
+ capsule_usd_total = 0
190
+ try:
191
+ capsule_page = self._get_page(capsule_info["page"])
192
+ for capsule_name, capsule_href in zip(capsule_info["names"], capsule_info["items"]):
193
+ config_capsule_name = capsule_name.replace(" ", "_").lower()
194
+ owned = self.config.getint(capsule_section, config_capsule_name, fallback=0)
195
+ if owned == 0:
196
+ continue
197
+
198
+ price_usd = self._parse_item_price(capsule_page, capsule_href)
199
+ price_usd_owned = round(float(owned * price_usd), 2)
200
+
201
+ console.print(f"[bold deep_sky_blue4]{capsule_name}")
202
+ console.print(PRICE_INFO.format(owned, price_usd, price_usd_owned))
203
+ if update_sheet_callback:
204
+ update_sheet_callback([capsule_name, owned, price_usd, price_usd_owned])
205
+ capsule_usd_total += price_usd_owned
206
+ except (RetryError, ValueError):
207
+ console.print(
208
+ "[bold red][!] Too many requests. (Consider using proxies to prevent rate limiting)\n"
209
+ )
210
+ except Exception as error:
211
+ console.print(f"[bold red][!] An unexpected error occurred: {error}\n")
212
+
213
+ return capsule_usd_total
214
+
215
+ def _scrape_capsule_section_prices(self, update_sheet_callback=None):
216
+ """
217
+ Scrape prices for all capsule sections defined in the configuration.
218
+
219
+ :param update_sheet_callback: Optional callback function to update a tksheet
220
+ that is displayed in the GUI with the latest scraper price calculation.
221
+ """
222
+ capsule_usd_total = 0
223
+ for capsule_section, capsule_info in CAPSULE_INFO.items():
224
+ # Only scrape capsule sections where the user owns at least one item
225
+ if any(int(owned) > 0 for _, owned in self.config.items(capsule_section)):
226
+ capsule_usd_total += self._scrape_capsule_prices(
227
+ capsule_section, capsule_info, update_sheet_callback
228
+ )
229
+
230
+ return capsule_usd_total
231
+
232
+ def _market_page_from_href(self, item_href):
233
+ """
234
+ Convert an href of a Steam Community Market item to a market page URL.
235
+
236
+ :param item_href: The href of the item listing, typically ending with the item's
237
+ name.
238
+ :return: A URL string for the Steam Community Market page of the item.
239
+ """
240
+ url_encoded_name = item_href.split("/")[-1]
241
+ page_url = f"https://steamcommunity.com/market/search?q={url_encoded_name}"
242
+
243
+ return page_url
244
+
245
+ def _scrape_case_prices(self, update_sheet_callback=None):
246
+ """
247
+ Scrape prices for all cases defined in the configuration.
248
+
249
+ For each case, it prints the case name, owned count, price per item, and total
250
+ price for owned items.
251
+
252
+ :param update_sheet_callback: Optional callback function to update a tksheet
253
+ that is displayed in the GUI with the latest scraper price calculation.
254
+ """
255
+ case_usd_total = 0
256
+ for case_index, (config_case_name, owned) in enumerate(self.config.items("Cases")):
257
+ if int(owned) == 0:
258
+ continue
259
+
260
+ case_name = config_case_name.replace("_", " ").title()
261
+ case_title = case_name.center(MAX_LINE_LEN, SEPARATOR)
262
+ console.print(f"[bold magenta]{case_title}\n")
263
+
264
+ try:
265
+ case_page_url = self._market_page_from_href(CASE_HREFS[case_index])
266
+ case_page = self._get_page(case_page_url)
267
+ price_usd = self._parse_item_price(case_page, CASE_HREFS[case_index])
268
+ price_usd_owned = round(float(int(owned) * price_usd), 2)
269
+
270
+ console.print(PRICE_INFO.format(owned, price_usd, price_usd_owned))
271
+ if update_sheet_callback:
272
+ update_sheet_callback([case_name, owned, price_usd, price_usd_owned])
273
+ case_usd_total += price_usd_owned
274
+
275
+ if not self.config.getboolean("App Settings", "use_proxy", fallback=False):
276
+ time.sleep(1)
277
+ except (RetryError, ValueError):
278
+ console.print(
279
+ "[bold red][!] Too many requests. (Consider using proxies to prevent rate limiting)\n"
280
+ )
281
+ except Exception as error:
282
+ console.print(f"[bold red][!] An unexpected error occurred: {error}\n")
283
+
284
+ return case_usd_total
285
+
286
+ def _scrape_custom_item_prices(self, update_sheet_callback=None):
287
+ """
288
+ Scrape prices for custom items defined in the configuration.
289
+
290
+ For each custom item, it prints the item name, owned count, price per item, and
291
+ total price for owned items.
292
+
293
+ :param update_sheet_callback: Optional callback function to update a tksheet
294
+ that is displayed in the GUI with the latest scraper price calculation.
295
+ """
296
+ custom_item_usd_total = 0
297
+ for custom_item_href, owned in self.config.items("Custom Items"):
298
+ if int(owned) == 0:
299
+ continue
300
+
301
+ custom_item_name = unquote(custom_item_href.split("/")[-1])
302
+ custom_item_title = custom_item_name.center(MAX_LINE_LEN, SEPARATOR)
303
+ console.print(f"[bold magenta]{custom_item_title}\n")
304
+
305
+ try:
306
+ custom_item_page_url = self._market_page_from_href(custom_item_href)
307
+ custom_item_page = self._get_page(custom_item_page_url)
308
+ price_usd = self._parse_item_price(custom_item_page, custom_item_href)
309
+ price_usd_owned = round(float(int(owned) * price_usd), 2)
310
+
311
+ console.print(PRICE_INFO.format(owned, price_usd, price_usd_owned))
312
+ if update_sheet_callback:
313
+ update_sheet_callback([custom_item_name, owned, price_usd, price_usd_owned])
314
+ custom_item_usd_total += price_usd_owned
315
+
316
+ if not self.config.getboolean("App Settings", "use_proxy", fallback=False):
317
+ time.sleep(1)
318
+ except (RetryError, ValueError):
319
+ console.print(
320
+ "[bold red][!] Too many requests. (Consider using proxies to prevent rate limiting)\n"
321
+ )
322
+ except Exception as error:
323
+ console.print(f"[bold red][!] An unexpected error occurred: {error}\n")
324
+
325
+ return custom_item_usd_total
326
+
327
+ def toggle_use_proxy(self, enabled: bool):
328
+ """
329
+ Toggle the use of proxies for requests. This will update the configuration file.
330
+
331
+ :param enabled: If True, proxies will be used; if False, they will not be used.
332
+ """
333
+ self.config.toggle_use_proxy(enabled)
334
+
335
+ def toggle_discord_webhook(self, enabled: bool):
336
+ """
337
+ Toggle the use of a Discord webhook to notify users of price calculations.
338
+
339
+ :param enabled: If True, the webhook will be used; if False, it will not be
340
+ used.
341
+ """
342
+ self.config.toggle_discord_webhook(enabled)
343
+
344
+
345
+ if __name__ == "__main__":
346
+ scraper = Scraper()
347
+ console.print(f"[bold yellow]{BANNER}\n{AUTHOR_STRING}\n")
348
+ scraper.scrape_prices()
@@ -0,0 +1,9 @@
1
+ from cs2tracker.util.padded_console import ( # noqa: F401 # pylint:disable=unused-import
2
+ PaddedConsole,
3
+ )
4
+ from cs2tracker.util.price_logs import ( # noqa: F401 # pylint:disable=unused-import
5
+ PriceLogs,
6
+ )
7
+ from cs2tracker.util.validated_config import ( # noqa: F401 # pylint:disable=unused-import
8
+ ValidatedConfig,
9
+ )
@@ -0,0 +1,100 @@
1
+ import csv
2
+ from datetime import datetime
3
+
4
+ from cs2tracker.constants import OUTPUT_FILE
5
+
6
+
7
+ class PriceLogs:
8
+ @classmethod
9
+ def _append_latest_calculation(cls, date, usd_total, eur_total):
10
+ """Append the first price calculation of the day."""
11
+ with open(OUTPUT_FILE, "a", newline="", encoding="utf-8") as price_logs:
12
+ price_logs_writer = csv.writer(price_logs)
13
+ price_logs_writer.writerow([date, f"{usd_total:.2f}$", f"{eur_total:.2f}€"])
14
+
15
+ @classmethod
16
+ def _replace_latest_calculation(cls, date, usd_total, eur_total):
17
+ """Replace the last calculation of today with the most recent one of today."""
18
+ with open(OUTPUT_FILE, "r+", newline="", encoding="utf-8") as price_logs:
19
+ price_logs_reader = csv.reader(price_logs)
20
+ rows = list(price_logs_reader)
21
+ rows_without_today = rows[:-1]
22
+ price_logs.seek(0)
23
+ price_logs.truncate()
24
+
25
+ price_logs_writer = csv.writer(price_logs)
26
+ price_logs_writer.writerows(rows_without_today)
27
+ price_logs_writer.writerow([date, f"{usd_total:.2f}$", f"{eur_total:.2f}€"])
28
+
29
+ @classmethod
30
+ def save(cls, usd_total, eur_total):
31
+ """
32
+ Save the current date and total prices in USD and EUR to a CSV file.
33
+
34
+ This will append a new entry to the output file if no entry has been made for
35
+ today.
36
+
37
+ :param usd_total: The total price in USD to save.
38
+ :param eur_total: The total price in EUR to save.
39
+ :raises FileNotFoundError: If the output file does not exist.
40
+ :raises IOError: If there is an error writing to the output file.
41
+ """
42
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as price_logs:
43
+ price_logs_reader = csv.reader(price_logs)
44
+ rows = list(price_logs_reader)
45
+ last_log_date, _, _ = rows[-1] if rows else ("", "", "")
46
+
47
+ today = datetime.now().strftime("%Y-%m-%d")
48
+ if last_log_date != today:
49
+ cls._append_latest_calculation(today, usd_total, eur_total)
50
+ else:
51
+ cls._replace_latest_calculation(today, usd_total, eur_total)
52
+
53
+ @classmethod
54
+ def read(cls):
55
+ """
56
+ Parse the output file to extract dates, dollar prices, and euro prices. This
57
+ data is used for drawing the plot of past prices.
58
+
59
+ :return: A tuple containing three lists: dates, dollar prices, and euro prices.
60
+ :raises FileNotFoundError: If the output file does not exist.
61
+ :raises IOError: If there is an error reading the output file.
62
+ """
63
+ dates, usd_prices, eur_prices = [], [], []
64
+ with open(OUTPUT_FILE, "r", encoding="utf-8") as price_logs:
65
+ price_logs_reader = csv.reader(price_logs)
66
+ for row in price_logs_reader:
67
+ date, price_usd, price_eur = row
68
+ date = datetime.strptime(date, "%Y-%m-%d")
69
+ price_usd = float(price_usd.rstrip("$"))
70
+ price_eur = float(price_eur.rstrip("€"))
71
+
72
+ dates.append(date)
73
+ usd_prices.append(price_usd)
74
+ eur_prices.append(price_eur)
75
+
76
+ return dates, usd_prices, eur_prices
77
+
78
+ @classmethod
79
+ def validate_file(cls, log_file_path):
80
+ """
81
+ Ensures that the provided price log file has the right format. This should be
82
+ used before importing a price log file to ensure it is valid.
83
+
84
+ :param log_file_path: The path to the price log file to validate.
85
+ :return: True if the price log file is valid, False otherwise.
86
+ """
87
+ try:
88
+ with open(log_file_path, "r", encoding="utf-8") as price_logs:
89
+ price_logs_reader = csv.reader(price_logs)
90
+ for row in price_logs_reader:
91
+ date_str, price_usd, price_eur = row
92
+ datetime.strptime(date_str, "%Y-%m-%d")
93
+ float(price_usd.rstrip("$"))
94
+ float(price_eur.rstrip("€"))
95
+ except (FileNotFoundError, IOError, ValueError, TypeError):
96
+ return False
97
+ except Exception:
98
+ return False
99
+
100
+ return True
@@ -0,0 +1,117 @@
1
+ import re
2
+ from configparser import ConfigParser
3
+
4
+ from cs2tracker.constants import CAPSULE_INFO, CONFIG_FILE
5
+ from cs2tracker.util.padded_console import PaddedConsole
6
+
7
+ console = PaddedConsole()
8
+
9
+
10
+ STEAM_MARKET_LISTING_REGEX = r"^https://steamcommunity.com/market/listings/\d+/.+$"
11
+
12
+
13
+ class ValidatedConfig(ConfigParser):
14
+ def __init__(self):
15
+ """Initialize the ValidatedConfig class."""
16
+ super().__init__(delimiters=("~"), interpolation=None)
17
+ self.optionxform = str # type: ignore
18
+ super().read(CONFIG_FILE)
19
+
20
+ self.valid = False
21
+ self.last_error = None
22
+ self._validate_config()
23
+
24
+ def _validate_config_sections(self):
25
+ """Validate that the configuration file has all required sections."""
26
+ if not self.has_section("User Settings"):
27
+ raise ValueError("Missing 'User Settings' section in the configuration file.")
28
+ if not self.has_section("App Settings"):
29
+ raise ValueError("Missing 'App Settings' section in the configuration file.")
30
+ if not self.has_section("Custom Items"):
31
+ raise ValueError("Missing 'Custom Items' section in the configuration file.")
32
+ if not self.has_section("Cases"):
33
+ raise ValueError("Missing 'Cases' section in the configuration file.")
34
+ for capsule_section in CAPSULE_INFO:
35
+ if not self.has_section(capsule_section):
36
+ raise ValueError(f"Missing '{capsule_section}' section in the configuration file.")
37
+
38
+ def _validate_config_values(self):
39
+ """Validate that the configuration file has valid values for all sections."""
40
+ try:
41
+ for custom_item_href, custom_item_owned in self.items("Custom Items"):
42
+ if not re.match(STEAM_MARKET_LISTING_REGEX, custom_item_href):
43
+ raise ValueError(
44
+ f"Invalid Steam market listing URL in 'Custom Items' section: {custom_item_href}"
45
+ )
46
+
47
+ if int(custom_item_owned) < 0:
48
+ raise ValueError(
49
+ f"Invalid value in 'Custom Items' section: {custom_item_href} = {custom_item_owned}"
50
+ )
51
+ for case_name, case_owned in self.items("Cases"):
52
+ if int(case_owned) < 0:
53
+ raise ValueError(
54
+ f"Invalid value in 'Cases' section: {case_name} = {case_owned}"
55
+ )
56
+ for capsule_section in CAPSULE_INFO:
57
+ for capsule_name, capsule_owned in self.items(capsule_section):
58
+ if int(capsule_owned) < 0:
59
+ raise ValueError(
60
+ f"Invalid value in '{capsule_section}' section: {capsule_name} = {capsule_owned}"
61
+ )
62
+ except ValueError as error:
63
+ if "Invalid " in str(error):
64
+ raise
65
+ raise ValueError("Invalid value type. All values must be integers.") from error
66
+
67
+ def _validate_config(self):
68
+ """
69
+ Validate the configuration file to ensure all required sections exist with the
70
+ right values.
71
+
72
+ :raises ValueError: If any required section is missing or if any value is
73
+ invalid.
74
+ """
75
+ try:
76
+ self._validate_config_sections()
77
+ self._validate_config_values()
78
+ self.valid = True
79
+ except ValueError as error:
80
+ console.print(f"[bold red][!] Config error: {error}")
81
+ self.valid = False
82
+ self.last_error = error
83
+
84
+ def write_to_file(self):
85
+ """Write the current configuration to the configuration file."""
86
+ self._validate_config()
87
+
88
+ if self.valid:
89
+ with open(CONFIG_FILE, "w", encoding="utf-8") as config_file:
90
+ self.write(config_file)
91
+
92
+ def toggle_use_proxy(self, enabled: bool):
93
+ """
94
+ Toggle the use of proxies for requests. This will update the configuration file.
95
+
96
+ :param enabled: If True, proxies will be used; if False, they will not be used.
97
+ """
98
+ self.set("App Settings", "use_proxy", str(enabled))
99
+ self.write_to_file()
100
+
101
+ console.print(
102
+ f"[bold green]{'[+] Enabled' if enabled else '[-] Disabled'} proxy usage for requests."
103
+ )
104
+
105
+ def toggle_discord_webhook(self, enabled: bool):
106
+ """
107
+ Toggle the use of a Discord webhook to notify users of price calculations.
108
+
109
+ :param enabled: If True, the webhook will be used; if False, it will not be
110
+ used.
111
+ """
112
+ self.set("App Settings", "discord_notifications", str(enabled))
113
+ self.write_to_file()
114
+
115
+ console.print(
116
+ f"[bold green]{'[+] Enabled' if enabled else '[-] Disabled'} Discord webhook notifications."
117
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cs2tracker
3
- Version: 2.1.9
3
+ Version: 2.1.11
4
4
  Summary: Tracking the steam market prices of CS2 items
5
5
  Home-page: https://github.com/ashiven/cs2tracker
6
6
  Author: Jannik Novak
@@ -19,6 +19,7 @@ Requires-Dist: Requests==2.31.0
19
19
  Requires-Dist: rich==13.6.0
20
20
  Requires-Dist: tenacity==8.2.2
21
21
  Requires-Dist: urllib3==2.1.0
22
+ Requires-Dist: sv_ttk==2.6.1
22
23
  Dynamic: license-file
23
24
 
24
25
  <div align="center">
@@ -46,7 +47,7 @@ Dynamic: license-file
46
47
 
47
48
  ### Setup
48
49
 
49
- #### Windows Executable _(no color support)_
50
+ #### Windows Executable
50
51
 
51
52
  - Simply [download the latest executable](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-windows.zip) and run it.
52
53
 
@@ -67,11 +68,13 @@ Dynamic: license-file
67
68
  ### Options
68
69
 
69
70
  - `Run!` to gather the current market prices of your items and calculate the total amount in USD and EUR.
70
- - `Edit Config` to specify the numbers of items owned in the config file. You can also add items other than cases and sticker capsules following the format in the `Custom Items` section. (item_name = item_owned item_page)
71
+ - `Edit Config` to specify the numbers of items owned in the configuration. You can also add items other than cases and sticker capsules via `Add Custom Item`
72
+ - `Reset Config` to reset the configuration to its original state. This will remove any custom items you have added and reset the number of items owned for all items.
71
73
  - `Show History` to see a price chart consisting of past calculations. A new data point is generated once a day upon running the program.
74
+ - `Export / Import History` to export the price history to a CSV file or import it from a CSV file. This may be used to back up your history data or perform further analysis on it.
72
75
  - `Daily Background Calculations` to automatically run a daily calculation of your investment in the background and save the results such that they can later be viewed via `Show History`.
73
76
  - `Receive Discord Notifications` to receive a notification on your Discord server when the program has finished calculating your investment. You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook url into the `discord_webhook_url` field in the config file.
74
- - `Proxy Requests` to prevent your requests from being rate limited by the steamcommunity server. You need to register for a free API key on [Crawlbase](crawlbase.com) and enter it into the `api_key` field in the config file.
77
+ - `Proxy Requests` to prevent your requests from being rate limited by the steamcommunity server. You need to register for a free API key on [Crawlbase](crawlbase.com) and enter it into the `proxy_api_key` field in the `User Settings` configuration section.
75
78
 
76
79
  ---
77
80
 
@@ -0,0 +1,25 @@
1
+ cs2tracker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cs2tracker/__main__.py,sha256=Ub--oSMv48YzfWF1CZqYlkn1-HvZ7Bhxoc7urn1oY6o,249
3
+ cs2tracker/_version.py,sha256=S_OVGIsyHqmFMeQkFD5U9tznSh-nCNTasS-8LEDRWIo,513
4
+ cs2tracker/constants.py,sha256=G6OaunSpb-SjIgzRSJA9LFczx5MPrXS3Qcq8eHBMa7I,27393
5
+ cs2tracker/main.py,sha256=kyQC0qvUYvd6Cgso48WnDCA8mJRijHMdlV4CffLuoBI,1023
6
+ cs2tracker/app/__init__.py,sha256=uqAxdDzoR2-2IrDc1riIU3Pi9vLEDwr68eg93-0RFmM,105
7
+ cs2tracker/app/application.py,sha256=mlO1P8fs4H_GEhOVaViwX6g9Xw0697qqB_Ypmq3sZwA,9445
8
+ cs2tracker/app/editor_frame.py,sha256=g6DLAct7ZPREDNP4gN6KkFQKxgqjpFyyq18n5h-AOQg,9786
9
+ cs2tracker/app/scraper_frame.py,sha256=VXDl942Jm1gPnw8DRhsYaxmMt_2N6GA0kz259iEsmX0,2576
10
+ cs2tracker/data/config.ini,sha256=pnd-s5X3x73KnLfjvRg5mfs4qfpv0ieyF-4AKq1EXTs,5576
11
+ cs2tracker/data/output.csv,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ cs2tracker/scraper/__init__.py,sha256=kfUB9yfshSche92ZvTqQQYBkVqujRez46djPoa9u0YU,335
13
+ cs2tracker/scraper/background_task.py,sha256=zuMqAaWFbrwDNKtO3kwynp1Dq8ATUCdlJlfReZQS_48,3703
14
+ cs2tracker/scraper/discord_notifier.py,sha256=pVmQohhG_61Pvq2zzqDDfImT6l5lGc-nQ_LPwpOLa3k,2994
15
+ cs2tracker/scraper/scraper.py,sha256=gpKziIzOGu7916bslJ3r2dXvJl6mLR2etopaLm752ww,14658
16
+ cs2tracker/util/__init__.py,sha256=TId6-M-sVLjPcwc3XvtdnluvQOB8C4NsQx4NHHIIzUg,329
17
+ cs2tracker/util/padded_console.py,sha256=lPEa34p-8LTmTbpf-2S5uYPaA2UmsIOPq2_UoVhMRgU,674
18
+ cs2tracker/util/price_logs.py,sha256=JuZ2ptDtxA-NzKjpG_4q0eeOr1IaUvVah-Qth6GrDDU,4165
19
+ cs2tracker/util/validated_config.py,sha256=WCkAhLsh16oFiemUV0Uap0NuecAVSEv-ceXwybh_AYk,4790
20
+ cs2tracker-2.1.11.dist-info/licenses/LICENSE.md,sha256=G5wqQ_8KGA808kVuF-Fpu_Yhteg8K_5ux9n2v8eQK7s,1069
21
+ cs2tracker-2.1.11.dist-info/METADATA,sha256=Yu1v6D5lfIIWnRsK7Rm4j1b51inNpC1QbpOkR_zUtMQ,4060
22
+ cs2tracker-2.1.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
+ cs2tracker-2.1.11.dist-info/entry_points.txt,sha256=K8IwDIkg8QztSB9g9c89B9jR_2pG4QyJGrNs4z5RcZw,63
24
+ cs2tracker-2.1.11.dist-info/top_level.txt,sha256=2HB4xDDOxaU5BDc_yvdi9UlYLgL768n8aR-hRhFM6VQ,11
25
+ cs2tracker-2.1.11.dist-info/RECORD,,