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

@@ -1,44 +1,63 @@
1
1
  import time
2
2
  from datetime import datetime
3
- from urllib.parse import unquote
4
3
 
5
- from bs4 import BeautifulSoup
6
- from bs4.element import Tag
7
4
  from currency_converter import CurrencyConverter
8
- from requests import RequestException, Session
5
+ from requests import RequestException
9
6
  from requests.adapters import HTTPAdapter, Retry
7
+ from requests_cache import CachedSession
10
8
  from tenacity import RetryError, retry, stop_after_attempt
11
9
 
12
- from cs2tracker.constants import AUTHOR_STRING, BANNER, CAPSULE_INFO, CASE_HREFS
10
+ from cs2tracker.constants import AUTHOR_STRING, BANNER
13
11
  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"
12
+ from cs2tracker.scraper.parsers import CSGOTrader, PriceSource
13
+ from cs2tracker.util import PriceLogs, get_config, get_console
19
14
 
20
15
  HTTP_PROXY_URL = "http://{}:@smartproxy.crawlbase.com:8012"
21
16
  HTTPS_PROXY_URL = "http://{}:@smartproxy.crawlbase.com:8012"
22
17
 
23
- console = PaddedConsole()
18
+ console = get_console()
19
+ config = get_config()
20
+
21
+
22
+ class ConfigError:
23
+ def __init__(self):
24
+ self.message = "Invalid configuration. Please fix the config file before running."
25
+
26
+
27
+ class ParsingError:
28
+ def __init__(self, message):
29
+ self.message = message
30
+
31
+
32
+ class RequestLimitExceededError:
33
+ def __init__(self):
34
+ self.message = "Too many requests. Consider using proxies to prevent rate limiting."
35
+
36
+
37
+ class PageLoadError:
38
+ def __init__(self, status_code):
39
+ self.message = f"Failed to load page: {status_code}. Retrying..."
40
+
41
+
42
+ class UnexpectedError:
43
+ def __init__(self, error):
44
+ self.message = f"An unexpected error occurred: {error}"
24
45
 
25
46
 
26
47
  class Scraper:
27
48
  def __init__(self):
28
49
  """Initialize the Scraper class."""
29
- self.load_config()
30
50
  self._start_session()
51
+ self._add_parser(CSGOTrader)
31
52
 
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()
53
+ self.error_stack = []
54
+ self.totals = {
55
+ price_source: {"usd": 0.0, "eur": 0.0} for price_source in self.parser.SOURCES
56
+ }
38
57
 
39
58
  def _start_session(self):
40
59
  """Start a requests session with custom headers and retry logic."""
41
- self.session = Session()
60
+ self.session = CachedSession("scraper_cache", backend="memory")
42
61
  self.session.headers.update(
43
62
  {
44
63
  "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"
@@ -48,6 +67,16 @@ class Scraper:
48
67
  self.session.mount("http://", HTTPAdapter(max_retries=retries))
49
68
  self.session.mount("https://", HTTPAdapter(max_retries=retries))
50
69
 
70
+ def _add_parser(self, parser):
71
+ """Add a parser for a specific page where item prices should be scraped."""
72
+ self.parser = parser
73
+
74
+ def _print_error(self):
75
+ """Print the last error message from the error stack, if any."""
76
+ last_error = self.error_stack[-1] if self.error_stack else None
77
+ if last_error:
78
+ console.error(f"{last_error.message}")
79
+
51
80
  def scrape_prices(self, update_sheet_callback=None):
52
81
  """
53
82
  Scrape prices for capsules and cases, calculate totals in USD and EUR, and
@@ -56,62 +85,73 @@ class Scraper:
56
85
  :param update_sheet_callback: Optional callback function to update a tksheet
57
86
  that is displayed in the GUI with the latest scraper price calculation.
58
87
  """
59
- if not self.config.valid:
60
- console.print(
61
- "[bold red][!] Invalid configuration. Please fix the config file before running."
62
- )
88
+ if not config.valid:
89
+ self.error_stack.append(ConfigError())
90
+ self._print_error()
63
91
  return
64
92
 
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)
93
+ # Reset totals from the previous run and clear the error stack
94
+ self.error_stack.clear()
95
+ self.totals = {
96
+ price_source: {"usd": 0.0, "eur": 0.0} for price_source in self.parser.SOURCES
97
+ }
68
98
 
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")
99
+ for section in config.sections():
100
+ if section in ("User Settings", "App Settings"):
101
+ continue
102
+ self._scrape_item_prices(section, update_sheet_callback)
103
+
104
+ for price_source, totals in self.totals.items():
105
+ usd_total = totals["usd"]
106
+ eur_total = CurrencyConverter().convert(usd_total, "USD", "EUR")
107
+ self.totals.update({price_source: {"usd": usd_total, "eur": eur_total}}) # type: ignore
73
108
 
74
109
  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
- )
110
+ update_sheet_callback(["", ""] + ["", ""] * len(self.parser.SOURCES))
111
+ for price_source, totals in self.totals.items():
112
+ usd_total = totals["usd"]
113
+ eur_total = totals["eur"]
114
+ update_sheet_callback(
115
+ [
116
+ f"[{datetime.now().strftime('%Y-%m-%d')}] {price_source.value.title()} Total:",
117
+ f"${usd_total:.2f}",
118
+ f"€{eur_total:.2f}",
119
+ "",
120
+ ]
121
+ )
84
122
 
85
123
  self._print_total()
86
- PriceLogs.save(self.usd_total, self.eur_total)
87
124
  self._send_discord_notification()
88
125
 
89
- # Reset totals for next run
90
- self.usd_total, self.eur_total = 0, 0
126
+ # TODO: modify price logs, charts etc for multiple sources (only use steam as source for now)
127
+ steam_usd_total = self.totals[PriceSource.STEAM]["usd"]
128
+ steam_eur_total = self.totals[PriceSource.STEAM]["eur"]
129
+ PriceLogs.save(steam_usd_total, steam_eur_total)
91
130
 
92
131
  def _print_total(self):
93
132
  """Print the total prices in USD and EUR, formatted with titles and
94
133
  separators.
95
134
  """
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}")
135
+ console.title("USD Total", "green")
136
+ for price_source, totals in self.totals.items():
137
+ usd_total = totals.get("usd")
138
+ console.print(f"{price_source.value.title():<10}: ${usd_total:.2f}")
99
139
 
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}")
140
+ console.title("EUR Total", "green")
141
+ for price_source, totals in self.totals.items():
142
+ eur_total = totals.get("eur")
143
+ console.print(f"{price_source.value.title():<10}: €{eur_total:.2f}")
103
144
 
104
- end_string = SEPARATOR * MAX_LINE_LEN
105
- console.print(f"[bold green]{end_string}\n")
145
+ console.separator("green")
106
146
 
107
147
  def _send_discord_notification(self):
108
148
  """Send a message to a Discord webhook if notifications are enabled in the
109
149
  config file and a webhook URL is provided.
110
150
  """
111
- discord_notifications = self.config.getboolean(
151
+ discord_notifications = config.getboolean(
112
152
  "App Settings", "discord_notifications", fallback=False
113
153
  )
114
- webhook_url = self.config.get("User Settings", "discord_webhook_url", fallback=None)
154
+ webhook_url = config.get("User Settings", "discord_webhook_url", fallback=None)
115
155
 
116
156
  if discord_notifications and webhook_url:
117
157
  DiscordNotifier.notify(webhook_url)
@@ -127,8 +167,8 @@ class Scraper:
127
167
  :raises RequestException: If the request fails.
128
168
  :raises RetryError: If the retry limit is reached.
129
169
  """
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)
170
+ use_proxy = config.getboolean("App Settings", "use_proxy", fallback=False)
171
+ proxy_api_key = config.get("User Settings", "proxy_api_key", fallback=None)
132
172
 
133
173
  if use_proxy and proxy_api_key:
134
174
  page = self.session.get(
@@ -143,203 +183,85 @@ class Scraper:
143
183
  page = self.session.get(url)
144
184
 
145
185
  if not page.ok or not page.content:
146
- console.print(f"[bold red][!] Failed to load page ({page.status_code}). Retrying...\n")
186
+ self.error_stack.append(PageLoadError(page.status_code))
187
+ self._print_error()
147
188
  raise RequestException(f"Failed to load page: {url}")
148
189
 
149
190
  return page
150
191
 
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):
192
+ def _scrape_prices_from_all_sources(self, item_href, owned):
216
193
  """
217
- Scrape prices for all capsule sections defined in the configuration.
194
+ For a given item href and owned count, scrape the item's price from all sources
195
+ available to the currently registered parser.
218
196
 
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.
197
+ :param item_href: The url of the steamcommunity market listing of the item
198
+ :param owned: How many of this item the user owns
199
+ :return: A list of item prices for the different sources
200
+ :raises RequestException: If the request fails.
201
+ :raises RetryError: If the retry limit is reached.
202
+ :raises ValueError: If the parser could not find the item
254
203
  """
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
-
204
+ prices = []
205
+ for price_source in self.parser.SOURCES:
264
206
  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
207
+ item_page_url = self.parser.get_item_page_url(item_href, price_source)
208
+ item_page = self._get_page(item_page_url)
209
+ price_usd = self.parser.parse_item_price(item_page, item_href, price_source)
274
210
 
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"
211
+ price_usd_owned = round(float(int(owned) * price_usd), 2)
212
+ self.totals[price_source]["usd"] += price_usd_owned
213
+
214
+ prices += [price_usd, price_usd_owned]
215
+ console.price(
216
+ self.parser.PRICE_INFO,
217
+ owned,
218
+ price_source.value.title(),
219
+ price_usd,
220
+ price_usd_owned,
280
221
  )
281
- except Exception as error:
282
- console.print(f"[bold red][!] An unexpected error occurred: {error}\n")
222
+ except ValueError as error:
223
+ prices += [0.0, 0.0]
224
+ self.error_stack.append(ParsingError(error))
225
+ self._print_error()
283
226
 
284
- return case_usd_total
227
+ return prices
285
228
 
286
- def _scrape_custom_item_prices(self, update_sheet_callback=None):
229
+ def _scrape_item_prices(self, section, update_sheet_callback=None):
287
230
  """
288
- Scrape prices for custom items defined in the configuration.
231
+ Scrape prices for all items defined in a configuration section that uses hrefs
232
+ as option keys.
289
233
 
290
- For each custom item, it prints the item name, owned count, price per item, and
291
- total price for owned items.
234
+ For each item, it prints the item name, owned count, price per item, and total
235
+ price for owned items.
292
236
 
293
237
  :param update_sheet_callback: Optional callback function to update a tksheet
294
238
  that is displayed in the GUI with the latest scraper price calculation.
295
239
  """
296
- custom_item_usd_total = 0
297
- for custom_item_href, owned in self.config.items("Custom Items"):
240
+ for item_href, owned in config.items(section):
241
+ if self.error_stack and isinstance(self.error_stack[-1], RequestLimitExceededError):
242
+ break
298
243
  if int(owned) == 0:
299
244
  continue
300
245
 
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
-
246
+ item_name = config.option_to_name(item_href, href=True)
247
+ console.title(item_name, "magenta")
305
248
  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)
249
+ prices = self._scrape_prices_from_all_sources(item_href, owned)
310
250
 
311
- console.print(PRICE_INFO.format(owned, price_usd, price_usd_owned))
312
251
  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
252
+ update_sheet_callback([item_name, owned] + prices)
315
253
 
316
- if not self.config.getboolean("App Settings", "use_proxy", fallback=False):
254
+ if (
255
+ not config.getboolean("App Settings", "use_proxy", fallback=False)
256
+ and self.parser.NEEDS_TIMEOUT
257
+ ):
317
258
  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
- )
259
+ except RetryError:
260
+ self.error_stack.append(RequestLimitExceededError())
261
+ self._print_error()
322
262
  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)
263
+ self.error_stack.append(UnexpectedError(error))
264
+ self._print_error()
343
265
 
344
266
 
345
267
  if __name__ == "__main__":
@@ -1,9 +1,9 @@
1
1
  from cs2tracker.util.padded_console import ( # noqa: F401 # pylint:disable=unused-import
2
- PaddedConsole,
2
+ get_console,
3
3
  )
4
4
  from cs2tracker.util.price_logs import ( # noqa: F401 # pylint:disable=unused-import
5
5
  PriceLogs,
6
6
  )
7
7
  from cs2tracker.util.validated_config import ( # noqa: F401 # pylint:disable=unused-import
8
- ValidatedConfig,
8
+ get_config,
9
9
  )
@@ -7,6 +7,10 @@ PADDING_LEFT = 4
7
7
  PADDING_RIGHT = 0
8
8
 
9
9
 
10
+ MAX_LINE_LEN = 72
11
+ SEPARATOR = "-"
12
+
13
+
10
14
  class PaddedConsole:
11
15
  def __init__(self, padding=(PADDING_TOP, PADDING_RIGHT, PADDING_BOTTOM, PADDING_LEFT)):
12
16
  """Initialize a PaddedConsole with specified padding."""
@@ -17,6 +21,34 @@ class PaddedConsole:
17
21
  """Print text with padding to the console."""
18
22
  self.console.print(Padding(text, self.padding))
19
23
 
24
+ def error(self, text):
25
+ """Print error text with padding to the console."""
26
+ text = "[bold red][!] " + text
27
+ self.print(text)
28
+
29
+ def title(self, text, color):
30
+ """Print the given text as a title."""
31
+ title = text.center(MAX_LINE_LEN, SEPARATOR)
32
+ console.print(f"\n[bold {color}]{title}\n")
33
+
34
+ def separator(self, color):
35
+ """Print a separator line."""
36
+ separator = SEPARATOR * MAX_LINE_LEN
37
+ console.print(f"[bold {color}]{separator}")
38
+
39
+ def price(self, price_str, price_source, owned, steam_market_price, total_owned):
40
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
41
+ """Print price information."""
42
+ console.print(price_str.format(price_source, owned, steam_market_price, total_owned))
43
+
20
44
  def __getattr__(self, attr):
21
45
  """Ensure console methods can be called directly on PaddedConsole."""
22
46
  return getattr(self.console, attr)
47
+
48
+
49
+ console = PaddedConsole()
50
+
51
+
52
+ def get_console():
53
+ """Get the PaddedConsole instance."""
54
+ return console