cs2tracker 2.1.10__tar.gz → 2.1.12__tar.gz
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-2.1.10 → cs2tracker-2.1.12}/.isort.cfg +1 -1
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/.pylintrc +2 -1
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/PKG-INFO +3 -3
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/README.md +1 -2
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/_version.py +2 -2
- cs2tracker-2.1.12/cs2tracker/app/__init__.py +3 -0
- cs2tracker-2.1.12/cs2tracker/app/application.py +255 -0
- cs2tracker-2.1.12/cs2tracker/app/editor_frame.py +247 -0
- cs2tracker-2.1.12/cs2tracker/app/scraper_frame.py +76 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/constants.py +101 -114
- cs2tracker-2.1.12/cs2tracker/data/config.ini +204 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/main.py +2 -2
- cs2tracker-2.1.12/cs2tracker/scraper/__init__.py +9 -0
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/scraper}/background_task.py +1 -1
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/scraper}/discord_notifier.py +1 -2
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/scraper}/scraper.py +51 -20
- cs2tracker-2.1.12/cs2tracker/util/__init__.py +9 -0
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/util}/validated_config.py +1 -1
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/PKG-INFO +3 -3
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/SOURCES.txt +13 -8
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/requires.txt +1 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/requirements.txt +1 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/setup.cfg +1 -0
- cs2tracker-2.1.10/cs2tracker/application.py +0 -516
- cs2tracker-2.1.10/cs2tracker/data/config.ini +0 -204
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/.flake8 +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/.gitignore +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/.pre-commit-config.yaml +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/LICENSE.md +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/MANIFEST.in +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/assets/icon.png +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/__init__.py +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/__main__.py +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker/data/output.csv +0 -0
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/util}/padded_console.py +0 -0
- {cs2tracker-2.1.10/cs2tracker → cs2tracker-2.1.12/cs2tracker/util}/price_logs.py +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/dependency_links.txt +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/entry_points.txt +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/cs2tracker.egg-info/top_level.txt +0 -0
- {cs2tracker-2.1.10 → cs2tracker-2.1.12}/pyproject.toml +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
[settings]
|
|
2
|
-
known_third_party = bs4,currency_converter,matplotlib,requests,rich,sv_ttk,tenacity,urllib3
|
|
2
|
+
known_third_party = bs4,currency_converter,matplotlib,requests,rich,sv_ttk,tenacity,tksheet,urllib3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cs2tracker
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.12
|
|
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
|
|
@@ -20,6 +20,7 @@ Requires-Dist: rich==13.6.0
|
|
|
20
20
|
Requires-Dist: tenacity==8.2.2
|
|
21
21
|
Requires-Dist: urllib3==2.1.0
|
|
22
22
|
Requires-Dist: sv_ttk==2.6.1
|
|
23
|
+
Requires-Dist: tksheet==7.5.12
|
|
23
24
|
Dynamic: license-file
|
|
24
25
|
|
|
25
26
|
<div align="center">
|
|
@@ -67,9 +68,8 @@ Dynamic: license-file
|
|
|
67
68
|
|
|
68
69
|
### Options
|
|
69
70
|
|
|
70
|
-
- `Run!` to gather the current market prices of your items and calculate the total amount in USD and EUR.
|
|
71
|
+
- `Run!` to gather the current market prices of your items and calculate the total amount in USD and EUR. The generated Excel sheet can be saved by right-clicking and then selecting `Save Sheet`.
|
|
71
72
|
- `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.
|
|
73
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
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.
|
|
75
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`.
|
|
@@ -43,9 +43,8 @@
|
|
|
43
43
|
|
|
44
44
|
### Options
|
|
45
45
|
|
|
46
|
-
- `Run!` to gather the current market prices of your items and calculate the total amount in USD and EUR.
|
|
46
|
+
- `Run!` to gather the current market prices of your items and calculate the total amount in USD and EUR. The generated Excel sheet can be saved by right-clicking and then selecting `Save Sheet`.
|
|
47
47
|
- `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`
|
|
48
|
-
- `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.
|
|
49
48
|
- `Show History` to see a price chart consisting of past calculations. A new data point is generated once a day upon running the program.
|
|
50
49
|
- `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.
|
|
51
50
|
- `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`.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from shutil import copy
|
|
4
|
+
from tkinter import messagebox, ttk
|
|
5
|
+
from tkinter.filedialog import askopenfilename, asksaveasfile
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import sv_ttk
|
|
10
|
+
from matplotlib.axes import Axes
|
|
11
|
+
from matplotlib.dates import DateFormatter
|
|
12
|
+
|
|
13
|
+
from cs2tracker.app.editor_frame import ConfigEditorFrame
|
|
14
|
+
from cs2tracker.app.scraper_frame import ScraperFrame
|
|
15
|
+
from cs2tracker.constants import ICON_FILE, OS, OUTPUT_FILE, OSType
|
|
16
|
+
from cs2tracker.scraper import BackgroundTask, Scraper
|
|
17
|
+
from cs2tracker.util import PriceLogs
|
|
18
|
+
|
|
19
|
+
APPLICATION_NAME = "CS2Tracker"
|
|
20
|
+
WINDOW_SIZE = "630x335"
|
|
21
|
+
DARK_THEME = True
|
|
22
|
+
|
|
23
|
+
SCRAPER_WINDOW_TITLE = "CS2Tracker Scraper"
|
|
24
|
+
SCRAPER_WINDOW_SIZE = "900x750"
|
|
25
|
+
|
|
26
|
+
CONFIG_EDITOR_TITLE = "Config Editor"
|
|
27
|
+
CONFIG_EDITOR_SIZE = "800x750"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Application:
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.scraper = Scraper()
|
|
33
|
+
self.application_window = None
|
|
34
|
+
|
|
35
|
+
def run(self):
|
|
36
|
+
"""Run the main application window with buttons for scraping prices, editing the
|
|
37
|
+
configuration, showing history in a chart, and editing the log file.
|
|
38
|
+
"""
|
|
39
|
+
self.application_window = self._configure_window()
|
|
40
|
+
|
|
41
|
+
if DARK_THEME:
|
|
42
|
+
sv_ttk.use_dark_theme()
|
|
43
|
+
else:
|
|
44
|
+
sv_ttk.use_light_theme()
|
|
45
|
+
|
|
46
|
+
self.application_window.mainloop()
|
|
47
|
+
|
|
48
|
+
def _add_button(self, frame, text, command, row):
|
|
49
|
+
"""Create and style a button for the button frame."""
|
|
50
|
+
grid_pos = {"row": row, "column": 0, "sticky": "ew", "padx": 10, "pady": 10}
|
|
51
|
+
button = ttk.Button(frame, text=text, command=command)
|
|
52
|
+
button.grid(**grid_pos)
|
|
53
|
+
|
|
54
|
+
def _configure_button_frame(self, main_frame):
|
|
55
|
+
"""Configure the button frame of the application main frame."""
|
|
56
|
+
button_frame = ttk.Frame(main_frame, style="Card.TFrame", padding=15)
|
|
57
|
+
button_frame.columnconfigure(0, weight=1)
|
|
58
|
+
button_frame.grid(row=0, column=0, padx=10, pady=(7, 20), sticky="nsew")
|
|
59
|
+
|
|
60
|
+
self._add_button(button_frame, "Run!", self.scrape_prices, 0)
|
|
61
|
+
self._add_button(button_frame, "Edit Config", self._edit_config, 1)
|
|
62
|
+
self._add_button(button_frame, "Show History", self._draw_plot, 2)
|
|
63
|
+
self._add_button(button_frame, "Export History", self._export_log_file, 3)
|
|
64
|
+
self._add_button(button_frame, "Import History", self._import_log_file, 4)
|
|
65
|
+
|
|
66
|
+
def _add_checkbox(
|
|
67
|
+
self, frame, text, variable, command, row
|
|
68
|
+
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
69
|
+
"""Create and style a checkbox for the checkbox frame."""
|
|
70
|
+
grid_pos = {"row": row, "column": 0, "sticky": "w", "padx": (10, 0), "pady": 5}
|
|
71
|
+
checkbox = ttk.Checkbutton(
|
|
72
|
+
frame,
|
|
73
|
+
text=text,
|
|
74
|
+
variable=variable,
|
|
75
|
+
command=command,
|
|
76
|
+
style="Switch.TCheckbutton",
|
|
77
|
+
)
|
|
78
|
+
checkbox.grid(**grid_pos)
|
|
79
|
+
|
|
80
|
+
def _configure_checkbox_frame(self, main_frame):
|
|
81
|
+
"""Configure the checkbox frame for background tasks and settings."""
|
|
82
|
+
checkbox_frame = ttk.LabelFrame(main_frame, text="Settings", padding=15)
|
|
83
|
+
checkbox_frame.grid(row=0, column=1, padx=10, pady=(0, 20), sticky="nsew")
|
|
84
|
+
|
|
85
|
+
background_checkbox_value = tk.BooleanVar(value=BackgroundTask.identify())
|
|
86
|
+
self._add_checkbox(
|
|
87
|
+
checkbox_frame,
|
|
88
|
+
"Background Task",
|
|
89
|
+
background_checkbox_value,
|
|
90
|
+
lambda: self._toggle_background_task(background_checkbox_value.get()),
|
|
91
|
+
0,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
discord_webhook_checkbox_value = tk.BooleanVar(
|
|
95
|
+
value=self.scraper.config.getboolean(
|
|
96
|
+
"App Settings", "discord_notifications", fallback=False
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
self._add_checkbox(
|
|
100
|
+
checkbox_frame,
|
|
101
|
+
"Discord Notifications",
|
|
102
|
+
discord_webhook_checkbox_value,
|
|
103
|
+
lambda: discord_webhook_checkbox_value.set(
|
|
104
|
+
self._toggle_discord_webhook(discord_webhook_checkbox_value.get())
|
|
105
|
+
),
|
|
106
|
+
1,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
use_proxy_checkbox_value = tk.BooleanVar(
|
|
110
|
+
value=self.scraper.config.getboolean("App Settings", "use_proxy", fallback=False)
|
|
111
|
+
)
|
|
112
|
+
self._add_checkbox(
|
|
113
|
+
checkbox_frame,
|
|
114
|
+
"Proxy Requests",
|
|
115
|
+
use_proxy_checkbox_value,
|
|
116
|
+
lambda: use_proxy_checkbox_value.set(
|
|
117
|
+
self._toggle_use_proxy(use_proxy_checkbox_value.get())
|
|
118
|
+
),
|
|
119
|
+
2,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# pylint: disable=attribute-defined-outside-init
|
|
123
|
+
self.dark_theme_checkbox_value = tk.BooleanVar(value=DARK_THEME)
|
|
124
|
+
self._add_checkbox(
|
|
125
|
+
checkbox_frame, "Dark Theme", self.dark_theme_checkbox_value, sv_ttk.toggle_theme, 3
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _configure_main_frame(self, window):
|
|
129
|
+
"""Configure the main frame of the application window with buttons and
|
|
130
|
+
checkboxes.
|
|
131
|
+
"""
|
|
132
|
+
main_frame = ttk.Frame(window, padding=15)
|
|
133
|
+
main_frame.columnconfigure(0, weight=1)
|
|
134
|
+
main_frame.columnconfigure(1, weight=1)
|
|
135
|
+
main_frame.rowconfigure(0, weight=1)
|
|
136
|
+
|
|
137
|
+
self._configure_button_frame(main_frame)
|
|
138
|
+
self._configure_checkbox_frame(main_frame)
|
|
139
|
+
|
|
140
|
+
main_frame.pack(expand=True, fill="both")
|
|
141
|
+
|
|
142
|
+
def _configure_window(self):
|
|
143
|
+
"""Configure the main application window UI and add buttons for the main
|
|
144
|
+
functionalities.
|
|
145
|
+
"""
|
|
146
|
+
window = tk.Tk()
|
|
147
|
+
window.title(APPLICATION_NAME)
|
|
148
|
+
window.geometry(WINDOW_SIZE)
|
|
149
|
+
|
|
150
|
+
if OS == OSType.WINDOWS:
|
|
151
|
+
app_id = "cs2tracker.unique.id"
|
|
152
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
153
|
+
|
|
154
|
+
icon = tk.PhotoImage(file=ICON_FILE)
|
|
155
|
+
window.wm_iconphoto(True, icon)
|
|
156
|
+
|
|
157
|
+
self._configure_main_frame(window)
|
|
158
|
+
|
|
159
|
+
return window
|
|
160
|
+
|
|
161
|
+
def scrape_prices(self):
|
|
162
|
+
"""Scrape prices from the configured sources, print the total, and save the
|
|
163
|
+
results to a file.
|
|
164
|
+
"""
|
|
165
|
+
scraper_window = tk.Toplevel(self.application_window)
|
|
166
|
+
scraper_window.geometry(SCRAPER_WINDOW_SIZE)
|
|
167
|
+
scraper_window.title(SCRAPER_WINDOW_TITLE)
|
|
168
|
+
|
|
169
|
+
run_frame = ScraperFrame(
|
|
170
|
+
scraper_window,
|
|
171
|
+
self.scraper,
|
|
172
|
+
sheet_size=SCRAPER_WINDOW_SIZE,
|
|
173
|
+
dark_theme=self.dark_theme_checkbox_value.get(),
|
|
174
|
+
)
|
|
175
|
+
run_frame.pack(expand=True, fill="both")
|
|
176
|
+
run_frame.start()
|
|
177
|
+
|
|
178
|
+
def _edit_config(self):
|
|
179
|
+
"""Open a new window with a config editor GUI."""
|
|
180
|
+
config_editor_window = tk.Toplevel(self.application_window)
|
|
181
|
+
config_editor_window.geometry(CONFIG_EDITOR_SIZE)
|
|
182
|
+
config_editor_window.title(CONFIG_EDITOR_TITLE)
|
|
183
|
+
|
|
184
|
+
editor_frame = ConfigEditorFrame(config_editor_window, self.scraper)
|
|
185
|
+
editor_frame.pack(expand=True, fill="both")
|
|
186
|
+
|
|
187
|
+
def _draw_plot(self):
|
|
188
|
+
"""Draw a plot of the scraped prices over time."""
|
|
189
|
+
dates, usd_prices, eur_prices = PriceLogs.read()
|
|
190
|
+
|
|
191
|
+
fig, ax_raw = plt.subplots(figsize=(10, 8), num="CS2Tracker Price History")
|
|
192
|
+
fig.suptitle("CS2Tracker Price History", fontsize=16)
|
|
193
|
+
fig.autofmt_xdate()
|
|
194
|
+
|
|
195
|
+
ax = cast(Axes, ax_raw)
|
|
196
|
+
ax.plot(dates, usd_prices, label="Dollars")
|
|
197
|
+
ax.plot(dates, eur_prices, label="Euros")
|
|
198
|
+
ax.legend()
|
|
199
|
+
date_formatter = DateFormatter("%Y-%m-%d")
|
|
200
|
+
ax.xaxis.set_major_formatter(date_formatter)
|
|
201
|
+
|
|
202
|
+
plt.show()
|
|
203
|
+
|
|
204
|
+
def _export_log_file(self):
|
|
205
|
+
"""Lets the user export the log file to a different location."""
|
|
206
|
+
export_path = asksaveasfile(
|
|
207
|
+
title="Export Log File",
|
|
208
|
+
defaultextension=".csv",
|
|
209
|
+
filetypes=[("CSV File", "*.csv")],
|
|
210
|
+
)
|
|
211
|
+
if export_path:
|
|
212
|
+
copy(OUTPUT_FILE, export_path.name)
|
|
213
|
+
|
|
214
|
+
def _import_log_file(self):
|
|
215
|
+
"""Lets the user import a log file from a different location."""
|
|
216
|
+
import_path = askopenfilename(
|
|
217
|
+
title="Import Log File",
|
|
218
|
+
defaultextension=".csv",
|
|
219
|
+
filetypes=[("CSV files", "*.csv")],
|
|
220
|
+
)
|
|
221
|
+
if not PriceLogs.validate_file(import_path):
|
|
222
|
+
return
|
|
223
|
+
copy(import_path, OUTPUT_FILE)
|
|
224
|
+
|
|
225
|
+
def _toggle_background_task(self, enabled: bool):
|
|
226
|
+
"""Toggle whether a daily price calculation should run in the background."""
|
|
227
|
+
BackgroundTask.toggle(enabled)
|
|
228
|
+
|
|
229
|
+
def _toggle_use_proxy(self, enabled: bool):
|
|
230
|
+
"""Toggle whether the scraper should use proxy servers for requests."""
|
|
231
|
+
proxy_api_key = self.scraper.config.get("User Settings", "proxy_api_key", fallback=None)
|
|
232
|
+
if not proxy_api_key and enabled:
|
|
233
|
+
messagebox.showerror(
|
|
234
|
+
"Config Error",
|
|
235
|
+
"You need to enter a valid crawlbase API key into the configuration to use this feature.",
|
|
236
|
+
)
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
self.scraper.toggle_use_proxy(enabled)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def _toggle_discord_webhook(self, enabled: bool):
|
|
243
|
+
"""Toggle whether the scraper should send notifications to a Discord webhook."""
|
|
244
|
+
discord_webhook_url = self.scraper.config.get(
|
|
245
|
+
"User Settings", "discord_webhook_url", fallback=None
|
|
246
|
+
)
|
|
247
|
+
if not discord_webhook_url and enabled:
|
|
248
|
+
messagebox.showerror(
|
|
249
|
+
"Config Error",
|
|
250
|
+
"You need to enter a valid Discord webhook URL into the configuration to use this feature.",
|
|
251
|
+
)
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
self.scraper.toggle_discord_webhook(enabled)
|
|
255
|
+
return True
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from shutil import copy
|
|
3
|
+
from tkinter import messagebox, ttk
|
|
4
|
+
|
|
5
|
+
from cs2tracker.constants import CONFIG_FILE, CONFIG_FILE_BACKUP
|
|
6
|
+
|
|
7
|
+
# from tksheet import Sheet
|
|
8
|
+
|
|
9
|
+
NEW_CUSTOM_ITEM_TITLE = "Add Custom Item"
|
|
10
|
+
NEW_CUSTOM_ITEM_SIZE = "500x200"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigEditorFrame(ttk.Frame):
|
|
14
|
+
def __init__(self, parent, scraper):
|
|
15
|
+
"""Initialize the configuration editor frame that allows users to view and edit
|
|
16
|
+
the configuration options.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
super().__init__(parent, style="Card.TFrame", padding=15)
|
|
20
|
+
|
|
21
|
+
self.parent = parent
|
|
22
|
+
self.scraper = scraper
|
|
23
|
+
self._add_widgets()
|
|
24
|
+
|
|
25
|
+
def reload_config_into_tree(self):
|
|
26
|
+
"""Reload the configuration options into the treeview for display and
|
|
27
|
+
editing.
|
|
28
|
+
"""
|
|
29
|
+
for item in self.tree.get_children():
|
|
30
|
+
self.tree.delete(item)
|
|
31
|
+
|
|
32
|
+
for section in self.scraper.config.sections():
|
|
33
|
+
if section == "App Settings":
|
|
34
|
+
continue
|
|
35
|
+
section_level = self.tree.insert("", "end", iid=section, text=section)
|
|
36
|
+
for config_option, value in self.scraper.config.items(section):
|
|
37
|
+
title_option = config_option.replace("_", " ").title()
|
|
38
|
+
self.tree.insert(section_level, "end", text=title_option, values=[value])
|
|
39
|
+
|
|
40
|
+
def _add_widgets(self):
|
|
41
|
+
"""Configure the main editor frame which displays the configuration options in a
|
|
42
|
+
structured way.
|
|
43
|
+
"""
|
|
44
|
+
self._configure_treeview()
|
|
45
|
+
self.tree.pack(expand=True, fill="both")
|
|
46
|
+
|
|
47
|
+
button_frame = ConfigEditorButtonFrame(self, self.scraper, self.tree)
|
|
48
|
+
button_frame.pack(side="bottom", padx=10, pady=(0, 10))
|
|
49
|
+
|
|
50
|
+
def _set_cell_value(self, event):
|
|
51
|
+
"""Set the value of a cell in the treeview to be editable when double-
|
|
52
|
+
clicked.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def save_edit(event):
|
|
56
|
+
self.tree.set(row, column=column, value=event.widget.get())
|
|
57
|
+
event.widget.destroy()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
row = self.tree.identify_row(event.y)
|
|
61
|
+
column = self.tree.identify_column(event.x)
|
|
62
|
+
item_text = self.tree.set(row, column)
|
|
63
|
+
if item_text.strip() == "":
|
|
64
|
+
left_item_text = self.tree.item(row, "text")
|
|
65
|
+
# Don't allow editing of section headers
|
|
66
|
+
if any(left_item_text == section for section in self.scraper.config.sections()):
|
|
67
|
+
return
|
|
68
|
+
x, y, w, h = self.tree.bbox(row, column)
|
|
69
|
+
entryedit = ttk.Entry(self)
|
|
70
|
+
entryedit.place(x=x, y=y, width=w, height=h + 3) # type: ignore
|
|
71
|
+
entryedit.insert("end", item_text)
|
|
72
|
+
entryedit.bind("<Return>", save_edit)
|
|
73
|
+
entryedit.focus_set()
|
|
74
|
+
entryedit.grab_set()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _destroy_entries(self, _):
|
|
79
|
+
"""Destroy any entry widgets in the treeview on an event, such as a mouse wheel
|
|
80
|
+
movement.
|
|
81
|
+
"""
|
|
82
|
+
for widget in self.winfo_children():
|
|
83
|
+
if isinstance(widget, ttk.Entry):
|
|
84
|
+
widget.destroy()
|
|
85
|
+
|
|
86
|
+
def _destroy_entry(self, event):
|
|
87
|
+
"""Destroy the entry widget on an even targeting it."""
|
|
88
|
+
if isinstance(event.widget, ttk.Entry):
|
|
89
|
+
event.widget.destroy()
|
|
90
|
+
|
|
91
|
+
def _make_tree_editable(self):
|
|
92
|
+
"""
|
|
93
|
+
Add a binding to the treeview that allows double-clicking on a cell to edit its
|
|
94
|
+
value.
|
|
95
|
+
|
|
96
|
+
Source: https://stackoverflow.com/questions/75787251/create-an-editable-tkinter-treeview-with-keyword-connection
|
|
97
|
+
"""
|
|
98
|
+
self.tree.bind("<Double-1>", self._set_cell_value)
|
|
99
|
+
self.parent.bind("<MouseWheel>", self._destroy_entries) # type: ignore
|
|
100
|
+
self.parent.bind("<Button-1>", self._destroy_entry) # type: ignore
|
|
101
|
+
|
|
102
|
+
def _load_config_into_tree(self):
|
|
103
|
+
"""Load the configuration options into the treeview for display and editing."""
|
|
104
|
+
for section in self.scraper.config.sections():
|
|
105
|
+
if section == "App Settings":
|
|
106
|
+
continue
|
|
107
|
+
section_level = self.tree.insert("", "end", iid=section, text=section)
|
|
108
|
+
for config_option, value in self.scraper.config.items(section):
|
|
109
|
+
title_option = config_option.replace("_", " ").title()
|
|
110
|
+
self.tree.insert(section_level, "end", text=title_option, values=[value])
|
|
111
|
+
|
|
112
|
+
def _configure_treeview(self):
|
|
113
|
+
"""Configure a treeview to display and edit configuration options."""
|
|
114
|
+
scrollbar = ttk.Scrollbar(self)
|
|
115
|
+
scrollbar.pack(side="right", fill="y", padx=(5, 0))
|
|
116
|
+
|
|
117
|
+
self.tree = ttk.Treeview( # pylint: disable=attribute-defined-outside-init
|
|
118
|
+
self,
|
|
119
|
+
columns=(1,),
|
|
120
|
+
height=10,
|
|
121
|
+
selectmode="browse",
|
|
122
|
+
yscrollcommand=scrollbar.set,
|
|
123
|
+
)
|
|
124
|
+
scrollbar.config(command=self.tree.yview)
|
|
125
|
+
|
|
126
|
+
self.tree.column("#0", anchor="w", width=200)
|
|
127
|
+
self.tree.column(1, anchor="w", width=25)
|
|
128
|
+
self.tree.heading("#0", text="Option")
|
|
129
|
+
self.tree.heading(1, text="Value")
|
|
130
|
+
|
|
131
|
+
self._load_config_into_tree()
|
|
132
|
+
self._make_tree_editable()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ConfigEditorButtonFrame(ttk.Frame):
|
|
136
|
+
def __init__(self, parent, scraper, tree):
|
|
137
|
+
"""Initialize the button frame that contains buttons for saving the updated
|
|
138
|
+
configuration and adding custom items.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
super().__init__(parent, padding=10)
|
|
142
|
+
|
|
143
|
+
self.parent = parent
|
|
144
|
+
self.scraper = scraper
|
|
145
|
+
self.tree = tree
|
|
146
|
+
self.custom_item_dialog = None
|
|
147
|
+
|
|
148
|
+
self._add_widgets()
|
|
149
|
+
|
|
150
|
+
def _add_widgets(self):
|
|
151
|
+
"""Add buttons to the button frame for saving the configuration and adding
|
|
152
|
+
custom items.
|
|
153
|
+
"""
|
|
154
|
+
save_button = ttk.Button(self, text="Save", command=self._save_config)
|
|
155
|
+
save_button.pack(side="left", expand=True, padx=5)
|
|
156
|
+
|
|
157
|
+
reset_button = ttk.Button(self, text="Reset", command=self._reset_config)
|
|
158
|
+
reset_button.pack(side="left", expand=True, padx=5)
|
|
159
|
+
|
|
160
|
+
custom_item_button = ttk.Button(
|
|
161
|
+
self, text="Add Custom Item", command=self._open_custom_item_dialog
|
|
162
|
+
)
|
|
163
|
+
custom_item_button.pack(side="left", expand=True, padx=5)
|
|
164
|
+
|
|
165
|
+
def _save_config(self):
|
|
166
|
+
"""Save the current configuration from the treeview to the config file."""
|
|
167
|
+
for child in self.tree.get_children():
|
|
168
|
+
for item in self.tree.get_children(child):
|
|
169
|
+
title_option = self.tree.item(item, "text")
|
|
170
|
+
config_option = title_option.lower().replace(" ", "_")
|
|
171
|
+
value = self.tree.item(item, "values")[0]
|
|
172
|
+
section = self.tree.parent(item)
|
|
173
|
+
section_name = self.tree.item(section, "text")
|
|
174
|
+
if section_name == "Custom Items":
|
|
175
|
+
# custom items are already saved upon creation (Saving them again would result in duplicates)
|
|
176
|
+
continue
|
|
177
|
+
self.scraper.config.set(section_name, config_option, value)
|
|
178
|
+
|
|
179
|
+
self.scraper.config.write_to_file()
|
|
180
|
+
if self.scraper.config.valid:
|
|
181
|
+
messagebox.showinfo("Config Saved", "The configuration has been saved successfully.")
|
|
182
|
+
else:
|
|
183
|
+
messagebox.showerror(
|
|
184
|
+
"Config Error",
|
|
185
|
+
f"The configuration is invalid. ({self.scraper.config.last_error})",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _reset_config(self):
|
|
189
|
+
"""Reset the configuration file to its default state."""
|
|
190
|
+
confirm = messagebox.askokcancel(
|
|
191
|
+
"Reset Config", "Are you sure you want to reset the configuration?"
|
|
192
|
+
)
|
|
193
|
+
if confirm:
|
|
194
|
+
copy(CONFIG_FILE_BACKUP, CONFIG_FILE)
|
|
195
|
+
self.scraper.load_config()
|
|
196
|
+
self.parent.reload_config_into_tree()
|
|
197
|
+
|
|
198
|
+
def _add_custom_item(self, item_url, item_owned):
|
|
199
|
+
"""Add a custom item to the configuration."""
|
|
200
|
+
if not item_url or not item_owned:
|
|
201
|
+
messagebox.showerror("Input Error", "All fields must be filled out.")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
if int(item_owned) < 0:
|
|
206
|
+
raise ValueError("Owned count must be a non-negative integer.")
|
|
207
|
+
except ValueError as error:
|
|
208
|
+
messagebox.showerror("Input Error", f"Invalid owned count: {error}")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
self.scraper.config.set("Custom Items", item_url, item_owned)
|
|
212
|
+
self.scraper.config.write_to_file()
|
|
213
|
+
if self.scraper.config.valid:
|
|
214
|
+
self.tree.insert("Custom Items", "end", text=item_url, values=(item_owned,))
|
|
215
|
+
if self.custom_item_dialog:
|
|
216
|
+
self.custom_item_dialog.destroy()
|
|
217
|
+
self.custom_item_dialog = None
|
|
218
|
+
else:
|
|
219
|
+
self.scraper.config.remove_option("Custom Items", item_url)
|
|
220
|
+
messagebox.showerror(
|
|
221
|
+
"Config Error",
|
|
222
|
+
f"The configuration is invalid. ({self.scraper.config.last_error})",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _open_custom_item_dialog(self):
|
|
226
|
+
"""Open a dialog to enter custom item details."""
|
|
227
|
+
self.custom_item_dialog = tk.Toplevel(self.parent)
|
|
228
|
+
self.custom_item_dialog.title(NEW_CUSTOM_ITEM_TITLE)
|
|
229
|
+
self.custom_item_dialog.geometry(NEW_CUSTOM_ITEM_SIZE)
|
|
230
|
+
|
|
231
|
+
dialog_frame = ttk.Frame(self.custom_item_dialog, padding=10)
|
|
232
|
+
dialog_frame.pack(expand=True, fill="both")
|
|
233
|
+
|
|
234
|
+
ttk.Label(dialog_frame, text="Item URL:").pack(pady=5)
|
|
235
|
+
item_url_entry = ttk.Entry(dialog_frame)
|
|
236
|
+
item_url_entry.pack(fill="x", padx=10)
|
|
237
|
+
|
|
238
|
+
ttk.Label(dialog_frame, text="Owned Count:").pack(pady=5)
|
|
239
|
+
item_owned_entry = ttk.Entry(dialog_frame)
|
|
240
|
+
item_owned_entry.pack(fill="x", padx=10)
|
|
241
|
+
|
|
242
|
+
add_button = ttk.Button(
|
|
243
|
+
dialog_frame,
|
|
244
|
+
text="Add",
|
|
245
|
+
command=lambda: self._add_custom_item(item_url_entry.get(), item_owned_entry.get()),
|
|
246
|
+
)
|
|
247
|
+
add_button.pack(pady=10)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
from tkinter import ttk
|
|
3
|
+
from tkinter.filedialog import asksaveasfilename
|
|
4
|
+
|
|
5
|
+
from tksheet import Sheet
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScraperFrame(ttk.Frame):
|
|
9
|
+
def __init__(self, parent, scraper, sheet_size, dark_theme):
|
|
10
|
+
"""Initialize the frame for running the scraper."""
|
|
11
|
+
super().__init__(parent)
|
|
12
|
+
|
|
13
|
+
self.parent = parent
|
|
14
|
+
self.scraper = scraper
|
|
15
|
+
self.sheet_width = sheet_size.split("x")[0]
|
|
16
|
+
self.sheet_height = sheet_size.split("x")[1]
|
|
17
|
+
self.dark_theme = dark_theme
|
|
18
|
+
|
|
19
|
+
self._add_widgets()
|
|
20
|
+
|
|
21
|
+
def _add_widgets(self):
|
|
22
|
+
"""Add widgets to the frame."""
|
|
23
|
+
self._configure_sheet()
|
|
24
|
+
self.sheet.pack()
|
|
25
|
+
|
|
26
|
+
def _configure_sheet(self):
|
|
27
|
+
"""Configure the sheet widget with initial data and settings."""
|
|
28
|
+
self.sheet = Sheet( # pylint: disable=attribute-defined-outside-init
|
|
29
|
+
self,
|
|
30
|
+
data=[],
|
|
31
|
+
theme="light" if self.dark_theme else "dark",
|
|
32
|
+
height=self.sheet_height,
|
|
33
|
+
width=self.sheet_width,
|
|
34
|
+
auto_resize_columns=150,
|
|
35
|
+
sticky="nsew",
|
|
36
|
+
)
|
|
37
|
+
self.sheet.enable_bindings()
|
|
38
|
+
self.sheet.insert_row(
|
|
39
|
+
["Item Name", "Item Owned", "Steam Market Price (USD)", "Total Value Owned (USD)"]
|
|
40
|
+
)
|
|
41
|
+
self.sheet.column_width(0, 220)
|
|
42
|
+
self.sheet.column_width(1, 20)
|
|
43
|
+
self.sheet.align_rows([0], "c")
|
|
44
|
+
self.sheet.align_columns([1, 2, 3], "c")
|
|
45
|
+
self.sheet.popup_menu_add_command("Save Sheet", self._save_sheet)
|
|
46
|
+
|
|
47
|
+
def _save_sheet(self):
|
|
48
|
+
"""Export the current sheet data to a CSV file."""
|
|
49
|
+
filepath = asksaveasfilename(
|
|
50
|
+
title="Save Price Sheet",
|
|
51
|
+
filetypes=[("CSV File", ".csv")],
|
|
52
|
+
defaultextension=".csv",
|
|
53
|
+
)
|
|
54
|
+
if filepath:
|
|
55
|
+
with open(filepath, "w", newline="", encoding="utf-8") as sheet_file:
|
|
56
|
+
writer = csv.writer(sheet_file)
|
|
57
|
+
writer.writerows(self.sheet.data)
|
|
58
|
+
|
|
59
|
+
def start(self):
|
|
60
|
+
"""Start the scraper and update the sheet with the latest price data in real-
|
|
61
|
+
time.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def update_sheet_callback(row):
|
|
65
|
+
"""Callback for the scraper to insert the latest price data into the
|
|
66
|
+
sheet.
|
|
67
|
+
"""
|
|
68
|
+
self.sheet.insert_row(row)
|
|
69
|
+
self.parent.update()
|
|
70
|
+
self.parent.update_idletasks()
|
|
71
|
+
|
|
72
|
+
self.scraper.scrape_prices(update_sheet_callback)
|
|
73
|
+
|
|
74
|
+
row_heights = self.sheet.get_row_heights()
|
|
75
|
+
last_row_index = len(row_heights) - 1
|
|
76
|
+
self.sheet.align_rows(last_row_index, "c")
|