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.
- cs2tracker/_version.py +2 -2
- cs2tracker/app/__init__.py +0 -3
- cs2tracker/app/app.py +283 -0
- cs2tracker/app/editor_frame.py +354 -158
- cs2tracker/app/history_frame.py +61 -0
- cs2tracker/app/scraper_frame.py +34 -11
- cs2tracker/config.py +263 -0
- cs2tracker/constants.py +58 -321
- cs2tracker/data/config.ini +157 -154
- cs2tracker/data/convert_inventory.js +108 -28
- cs2tracker/data/get_inventory.js +96 -32
- cs2tracker/logs.py +143 -0
- cs2tracker/main.py +4 -19
- cs2tracker/scraper/__init__.py +0 -9
- cs2tracker/scraper/background_task.py +1 -1
- cs2tracker/scraper/discord_notifier.py +34 -32
- cs2tracker/scraper/parser.py +189 -0
- cs2tracker/scraper/scraper.py +166 -246
- cs2tracker/util/__init__.py +0 -9
- cs2tracker/util/currency_conversion.py +84 -0
- cs2tracker/util/padded_console.py +24 -0
- cs2tracker/util/tkinter.py +55 -0
- {cs2tracker-2.1.13.dist-info → cs2tracker-2.1.15.dist-info}/METADATA +6 -6
- cs2tracker-2.1.15.dist-info/RECORD +31 -0
- cs2tracker/app/application.py +0 -255
- cs2tracker/util/price_logs.py +0 -100
- cs2tracker/util/validated_config.py +0 -164
- cs2tracker-2.1.13.dist-info/RECORD +0 -27
- {cs2tracker-2.1.13.dist-info → cs2tracker-2.1.15.dist-info}/WHEEL +0 -0
- {cs2tracker-2.1.13.dist-info → cs2tracker-2.1.15.dist-info}/entry_points.txt +0 -0
- {cs2tracker-2.1.13.dist-info → cs2tracker-2.1.15.dist-info}/licenses/LICENSE +0 -0
- {cs2tracker-2.1.13.dist-info → cs2tracker-2.1.15.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from tkinter import ttk
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
from matplotlib.axes import Axes
|
|
6
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
7
|
+
from matplotlib.dates import DateFormatter
|
|
8
|
+
|
|
9
|
+
from cs2tracker.config import get_config
|
|
10
|
+
from cs2tracker.logs import PriceLogs
|
|
11
|
+
from cs2tracker.scraper.parser import Parser
|
|
12
|
+
|
|
13
|
+
config = get_config()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PriceHistoryFrame(ttk.Frame):
|
|
17
|
+
# pylint: disable=attribute-defined-outside-init
|
|
18
|
+
def __init__(self, parent):
|
|
19
|
+
"""Initialize the price history frame."""
|
|
20
|
+
super().__init__(parent)
|
|
21
|
+
|
|
22
|
+
self._add_widgets()
|
|
23
|
+
|
|
24
|
+
def _add_widgets(self):
|
|
25
|
+
"""Add widgets to the frame."""
|
|
26
|
+
self._configure_canvas()
|
|
27
|
+
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
|
28
|
+
|
|
29
|
+
self.toolbar = NavigationToolbar2Tk(self.canvas, self)
|
|
30
|
+
self.toolbar.update()
|
|
31
|
+
self.toolbar.pack()
|
|
32
|
+
|
|
33
|
+
def _configure_canvas(self):
|
|
34
|
+
"""Configure the canvas on which the price history chart is drawn."""
|
|
35
|
+
self._draw_plot()
|
|
36
|
+
plt.close(self.fig)
|
|
37
|
+
|
|
38
|
+
self.canvas = FigureCanvasTkAgg(self.fig, master=self)
|
|
39
|
+
self.canvas.draw()
|
|
40
|
+
|
|
41
|
+
def _draw_plot(self):
|
|
42
|
+
"""Draw a chart of the price history."""
|
|
43
|
+
|
|
44
|
+
self.fig, ax_raw = plt.subplots(dpi=100)
|
|
45
|
+
self.fig.autofmt_xdate()
|
|
46
|
+
ax = cast(Axes, ax_raw)
|
|
47
|
+
|
|
48
|
+
dates, totals = PriceLogs.read()
|
|
49
|
+
for price_source in Parser.SOURCES:
|
|
50
|
+
usd_prices = totals[price_source]["USD"]
|
|
51
|
+
converted_prices = totals[price_source][config.conversion_currency]
|
|
52
|
+
ax.plot(dates, usd_prices, label=f"{price_source.name.title()}: USD")
|
|
53
|
+
ax.plot(
|
|
54
|
+
dates,
|
|
55
|
+
converted_prices,
|
|
56
|
+
label=f"{price_source.name.title()}: {config.conversion_currency}",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ax.legend(loc="upper left", fontsize="small")
|
|
60
|
+
date_formatter = DateFormatter("%Y-%m-%d")
|
|
61
|
+
ax.xaxis.set_major_formatter(date_formatter)
|
cs2tracker/app/scraper_frame.py
CHANGED
|
@@ -4,6 +4,10 @@ from tkinter.filedialog import asksaveasfilename
|
|
|
4
4
|
|
|
5
5
|
from tksheet import Sheet
|
|
6
6
|
|
|
7
|
+
from cs2tracker.scraper.parser import Parser
|
|
8
|
+
from cs2tracker.scraper.scraper import ParsingError, SheetNotFoundError
|
|
9
|
+
from cs2tracker.util.tkinter import centered
|
|
10
|
+
|
|
7
11
|
|
|
8
12
|
class ScraperFrame(ttk.Frame):
|
|
9
13
|
def __init__(self, parent, scraper, sheet_size, dark_theme):
|
|
@@ -43,18 +47,38 @@ class ScraperFrame(ttk.Frame):
|
|
|
43
47
|
height=self.sheet_height,
|
|
44
48
|
width=self.sheet_width,
|
|
45
49
|
auto_resize_columns=150,
|
|
50
|
+
default_column_width=150,
|
|
46
51
|
sticky="nsew",
|
|
47
52
|
)
|
|
48
53
|
self.sheet.enable_bindings()
|
|
54
|
+
|
|
55
|
+
source_titles = []
|
|
56
|
+
for price_source in Parser.SOURCES:
|
|
57
|
+
source_titles += [
|
|
58
|
+
f"{price_source.name.title()} (USD)",
|
|
59
|
+
f"{price_source.name.title()} Owned (USD)",
|
|
60
|
+
]
|
|
49
61
|
self.sheet.insert_row(
|
|
50
|
-
[
|
|
62
|
+
[
|
|
63
|
+
"Item Name",
|
|
64
|
+
"Item Owned",
|
|
65
|
+
]
|
|
66
|
+
+ source_titles
|
|
51
67
|
)
|
|
52
|
-
self.sheet.column_width(0, 220)
|
|
53
|
-
self.sheet.column_width(1, 20)
|
|
54
68
|
self.sheet.align_rows([0], "c")
|
|
55
|
-
self.sheet.align_columns([1, 2, 3], "c")
|
|
56
|
-
self.sheet.popup_menu_add_command("Save Sheet", self._save_sheet)
|
|
57
69
|
|
|
70
|
+
price_columns = list(range(2 * len(Parser.SOURCES)))
|
|
71
|
+
price_columns = [1] + [column_index + 2 for column_index in price_columns]
|
|
72
|
+
self.sheet.align_columns(price_columns, "c")
|
|
73
|
+
self.sheet.column_width(0, 220)
|
|
74
|
+
|
|
75
|
+
required_window_width = 220 + 150 * len(price_columns)
|
|
76
|
+
if int(self.sheet_width) < required_window_width:
|
|
77
|
+
self.parent.geometry(
|
|
78
|
+
centered(self.parent, f"{required_window_width}x{self.sheet_height}")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.sheet.popup_menu_add_command("Save Sheet", self._save_sheet)
|
|
58
82
|
self.parent.bind("<Configure>", self._readjust_sheet_size_with_window_size)
|
|
59
83
|
|
|
60
84
|
def _save_sheet(self):
|
|
@@ -84,10 +108,9 @@ class ScraperFrame(ttk.Frame):
|
|
|
84
108
|
|
|
85
109
|
self.scraper.scrape_prices(update_sheet_callback)
|
|
86
110
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if self.scraper.error_stack:
|
|
111
|
+
if self.scraper.error_stack and not isinstance(
|
|
112
|
+
self.scraper.error_stack[-1], SheetNotFoundError
|
|
113
|
+
):
|
|
92
114
|
last_error = self.scraper.error_stack[-1]
|
|
93
|
-
|
|
115
|
+
if not isinstance(last_error, ParsingError):
|
|
116
|
+
messagebox.showerror("An Error Occurred", f"{last_error.message}", parent=self)
|
cs2tracker/config.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from configparser import ConfigParser, ParsingError
|
|
4
|
+
from urllib.parse import quote, unquote
|
|
5
|
+
|
|
6
|
+
from cs2tracker.constants import CAPSULE_PAGES, CONFIG_FILE, INVENTORY_IMPORT_FILE
|
|
7
|
+
from cs2tracker.util.padded_console import get_console
|
|
8
|
+
|
|
9
|
+
STEAM_MARKET_LISTING_BASEURL_CS2 = "https://steamcommunity.com/market/listings/730/"
|
|
10
|
+
STEAM_MARKET_LISTING_REGEX = r"^https://steamcommunity.com/market/listings/\d+/.+$"
|
|
11
|
+
|
|
12
|
+
console = get_console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidatedConfig(ConfigParser):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
"""Initialize the ValidatedConfig class."""
|
|
18
|
+
super().__init__(delimiters=("~"), interpolation=None)
|
|
19
|
+
self.optionxform = str # type: ignore
|
|
20
|
+
|
|
21
|
+
self.valid = False
|
|
22
|
+
self.last_error = None
|
|
23
|
+
try:
|
|
24
|
+
self.load_from_file()
|
|
25
|
+
except (FileNotFoundError, ParsingError) as error:
|
|
26
|
+
console.error(f"Config error: {error}")
|
|
27
|
+
self.last_error = error
|
|
28
|
+
|
|
29
|
+
def delete_display_sections(self):
|
|
30
|
+
"""
|
|
31
|
+
Delete all sections that are displayed to the user from the config.
|
|
32
|
+
|
|
33
|
+
(This excludes the internal App Settings section)
|
|
34
|
+
"""
|
|
35
|
+
use_proxy = self.getboolean("App Settings", "use_proxy", fallback=False)
|
|
36
|
+
discord_notifications = self.getboolean(
|
|
37
|
+
"App Settings", "discord_notifications", fallback=False
|
|
38
|
+
)
|
|
39
|
+
conversion_currency = self.get("App Settings", "conversion_currency", fallback="EUR")
|
|
40
|
+
|
|
41
|
+
self.clear()
|
|
42
|
+
self.add_section("App Settings")
|
|
43
|
+
self.set("App Settings", "use_proxy", str(use_proxy))
|
|
44
|
+
self.set("App Settings", "discord_notifications", str(discord_notifications))
|
|
45
|
+
self.set("App Settings", "conversion_currency", conversion_currency)
|
|
46
|
+
|
|
47
|
+
def _validate_config_sections(self):
|
|
48
|
+
"""Validate that the configuration file has all required sections."""
|
|
49
|
+
if not self.has_section("User Settings"):
|
|
50
|
+
raise ValueError("Missing 'User Settings' section in the configuration file.")
|
|
51
|
+
if not self.has_section("App Settings"):
|
|
52
|
+
raise ValueError("Missing 'App Settings' section in the configuration file.")
|
|
53
|
+
if not self.has_section("Stickers"):
|
|
54
|
+
raise ValueError("Missing 'Stickers' section in the configuration file.")
|
|
55
|
+
if not self.has_section("Cases"):
|
|
56
|
+
raise ValueError("Missing 'Cases' section in the configuration file.")
|
|
57
|
+
if not self.has_section("Skins"):
|
|
58
|
+
raise ValueError("Missing 'Skins' section in the configuration file.")
|
|
59
|
+
for capsule_section in CAPSULE_PAGES:
|
|
60
|
+
if not self.has_section(capsule_section):
|
|
61
|
+
raise ValueError(f"Missing '{capsule_section}' section in the configuration file.")
|
|
62
|
+
|
|
63
|
+
def _validate_config_values(self):
|
|
64
|
+
# pylint: disable=too-many-branches
|
|
65
|
+
"""Validate that the configuration file has valid values for all sections."""
|
|
66
|
+
try:
|
|
67
|
+
for section in self.sections():
|
|
68
|
+
if section == "App Settings":
|
|
69
|
+
for option in ("use_proxy", "discord_notifications", "conversion_currency"):
|
|
70
|
+
if not self.has_option(section, option):
|
|
71
|
+
raise ValueError(f"Reason: Missing '{option}' in '{section}' section.")
|
|
72
|
+
if option in ("use_proxy", "discord_notifications") and self.get(
|
|
73
|
+
section, option, fallback=False
|
|
74
|
+
) not in ("True", "False"):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Reason: Invalid value for '{option}' in '{section}' section."
|
|
77
|
+
)
|
|
78
|
+
elif section == "User Settings":
|
|
79
|
+
for option in ("proxy_api_key", "discord_webhook_url"):
|
|
80
|
+
if not self.has_option(section, option):
|
|
81
|
+
raise ValueError(f"Reason: Missing '{option}' in '{section}' section.")
|
|
82
|
+
else:
|
|
83
|
+
for item_href, item_owned in self.items(section):
|
|
84
|
+
if not re.match(STEAM_MARKET_LISTING_REGEX, item_href):
|
|
85
|
+
raise ValueError("Reason: Invalid Steam market listing URL.")
|
|
86
|
+
if int(item_owned) < 0:
|
|
87
|
+
raise ValueError("Reason: Negative values are not allowed.")
|
|
88
|
+
if int(item_owned) > 1000000:
|
|
89
|
+
raise ValueError("Reason: Value exceeds maximum limit of 1,000,000.")
|
|
90
|
+
except ValueError as error:
|
|
91
|
+
# Re-raise the error if it contains "Reason: " to maintain the original message
|
|
92
|
+
# and raise a ValueError if the conversion of a value to an integer fails.
|
|
93
|
+
if "Reason: " in str(error):
|
|
94
|
+
raise
|
|
95
|
+
raise ValueError("Reason: Invalid value type. All values must be integers.") from error
|
|
96
|
+
|
|
97
|
+
def _validate_config(self):
|
|
98
|
+
"""
|
|
99
|
+
Validate the configuration file to ensure all required sections exist with the
|
|
100
|
+
right values.
|
|
101
|
+
|
|
102
|
+
:raises ValueError: If any required section is missing or if any value is
|
|
103
|
+
invalid.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
self._validate_config_sections()
|
|
107
|
+
self._validate_config_values()
|
|
108
|
+
self.valid = True
|
|
109
|
+
except ValueError as error:
|
|
110
|
+
console.error(f"Config error: {error}")
|
|
111
|
+
self.valid = False
|
|
112
|
+
self.last_error = error
|
|
113
|
+
|
|
114
|
+
def load_from_file(self):
|
|
115
|
+
"""Load the configuration file and validate it."""
|
|
116
|
+
self.clear()
|
|
117
|
+
self.read(CONFIG_FILE)
|
|
118
|
+
self._validate_config()
|
|
119
|
+
|
|
120
|
+
def write_to_file(self):
|
|
121
|
+
"""Validate the current configuration and write it to the configuration file if
|
|
122
|
+
it is valid.
|
|
123
|
+
"""
|
|
124
|
+
self._validate_config()
|
|
125
|
+
|
|
126
|
+
if self.valid:
|
|
127
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as config_file:
|
|
128
|
+
self.write(config_file)
|
|
129
|
+
|
|
130
|
+
def read_from_inventory_file(self):
|
|
131
|
+
"""
|
|
132
|
+
Read an inventory file into the configuration.
|
|
133
|
+
|
|
134
|
+
This file is generated after a user automatically imports their inventory.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
with open(INVENTORY_IMPORT_FILE, "r", encoding="utf-8") as inventory_file:
|
|
138
|
+
inventory_data = json.load(inventory_file)
|
|
139
|
+
sorted_inventory_data = dict(sorted(inventory_data.items()))
|
|
140
|
+
|
|
141
|
+
added_to_config = set()
|
|
142
|
+
for item_name, item_owned in sorted_inventory_data.items():
|
|
143
|
+
option = self.name_to_option(item_name, href=True)
|
|
144
|
+
for section in self.sections():
|
|
145
|
+
if option in self.options(section):
|
|
146
|
+
self.set(section, option, str(item_owned))
|
|
147
|
+
added_to_config.add(item_name)
|
|
148
|
+
|
|
149
|
+
for item_name, item_owned in sorted_inventory_data.items():
|
|
150
|
+
if item_name not in added_to_config:
|
|
151
|
+
option = self.name_to_option(item_name, href=True)
|
|
152
|
+
if item_name.startswith("Sticker"):
|
|
153
|
+
self.set("Stickers", option, str(item_owned))
|
|
154
|
+
else:
|
|
155
|
+
self.set("Skins", option, str(item_owned))
|
|
156
|
+
|
|
157
|
+
self.write_to_file()
|
|
158
|
+
except (FileNotFoundError, json.JSONDecodeError) as error:
|
|
159
|
+
console.error(f"Error reading inventory file: {error}")
|
|
160
|
+
self.last_error = error
|
|
161
|
+
self.valid = False
|
|
162
|
+
|
|
163
|
+
def option_to_name(self, option, href=False):
|
|
164
|
+
"""
|
|
165
|
+
Convert an internal option representation to a reader-friendly name.
|
|
166
|
+
|
|
167
|
+
:param option: The internal option representation to convert.
|
|
168
|
+
:param custom: If True, the option is for a custom item.
|
|
169
|
+
:return: The reader-friendly name.
|
|
170
|
+
"""
|
|
171
|
+
if href:
|
|
172
|
+
if not re.match(STEAM_MARKET_LISTING_REGEX, option):
|
|
173
|
+
raise ValueError(f"Invalid Steam market listing URL: {option}")
|
|
174
|
+
|
|
175
|
+
converted_option = unquote(option.split("/")[-1])
|
|
176
|
+
else:
|
|
177
|
+
converted_option = option.replace("_", " ").title()
|
|
178
|
+
|
|
179
|
+
return converted_option
|
|
180
|
+
|
|
181
|
+
def name_to_option(self, name, href=False):
|
|
182
|
+
"""
|
|
183
|
+
Convert a reader-friendly name to an internal option representation.
|
|
184
|
+
|
|
185
|
+
:param name: The reader-friendly name to convert.
|
|
186
|
+
:param custom: If True, the name is for a custom item.
|
|
187
|
+
:return: The internal option representation.
|
|
188
|
+
"""
|
|
189
|
+
if href:
|
|
190
|
+
converted_name = STEAM_MARKET_LISTING_BASEURL_CS2 + quote(name)
|
|
191
|
+
else:
|
|
192
|
+
converted_name = name.replace(" ", "_").lower()
|
|
193
|
+
|
|
194
|
+
return converted_name
|
|
195
|
+
|
|
196
|
+
def toggle_app_option(self, option, enabled):
|
|
197
|
+
"""
|
|
198
|
+
Toggle the use of proxies for requests. This will update the configuration file.
|
|
199
|
+
|
|
200
|
+
:param enabled: If True, proxies will be used; if False, they will not be used.
|
|
201
|
+
"""
|
|
202
|
+
self.set("App Settings", option, str(enabled))
|
|
203
|
+
self.write_to_file()
|
|
204
|
+
|
|
205
|
+
console.info(f"{'Enabled' if enabled else 'Disabled'} option: {option}.")
|
|
206
|
+
|
|
207
|
+
def set_app_option(self, option, value):
|
|
208
|
+
"""
|
|
209
|
+
Set an option in the App Settings to a specific value.
|
|
210
|
+
|
|
211
|
+
:param option: The option to set.
|
|
212
|
+
:param value: The value to set the option to.
|
|
213
|
+
"""
|
|
214
|
+
self.set("App Settings", option, str(value))
|
|
215
|
+
self.write_to_file()
|
|
216
|
+
|
|
217
|
+
console.info(f"Set {option} to {value}.")
|
|
218
|
+
|
|
219
|
+
def option_exists(self, option, exclude_sections=()):
|
|
220
|
+
"""
|
|
221
|
+
Check if an option exists in any section of the configuration.
|
|
222
|
+
|
|
223
|
+
:param option: The option to check.
|
|
224
|
+
:param exclude_sections: Sections to exclude from the check.
|
|
225
|
+
:return: True if the option exists, False otherwise.
|
|
226
|
+
"""
|
|
227
|
+
for section in [section for section in self.sections() if section not in exclude_sections]:
|
|
228
|
+
if option in self.options(section):
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def use_proxy(self):
|
|
234
|
+
"""Check if the application should use proxies for requests."""
|
|
235
|
+
return self.getboolean("App Settings", "use_proxy", fallback=False)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def discord_notifications(self):
|
|
239
|
+
"""Check if the application should send Discord notifications."""
|
|
240
|
+
return self.getboolean("App Settings", "discord_notifications", fallback=False)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def conversion_currency(self):
|
|
244
|
+
"""Get the conversion currency for price calculations."""
|
|
245
|
+
return self.get("App Settings", "conversion_currency", fallback="EUR")
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def proxy_api_key(self):
|
|
249
|
+
"""Get the API key for the proxy service."""
|
|
250
|
+
return self.get("User Settings", "proxy_api_key", fallback="")
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def discord_webhook_url(self):
|
|
254
|
+
"""Get the Discord webhook URL for notifications."""
|
|
255
|
+
return self.get("User Settings", "discord_webhook_url", fallback="")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
config = ValidatedConfig()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_config():
|
|
262
|
+
"""Accessor function to retrieve the current configuration."""
|
|
263
|
+
return config
|