cs2tracker 2.1.8__py3-none-any.whl → 2.1.10__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/application.py +349 -86
- cs2tracker/background_task.py +109 -0
- cs2tracker/constants.py +32 -55
- cs2tracker/data/config.ini +155 -156
- cs2tracker/discord_notifier.py +87 -0
- cs2tracker/price_logs.py +100 -0
- cs2tracker/scraper.py +105 -355
- cs2tracker/validated_config.py +117 -0
- {cs2tracker-2.1.8.dist-info → cs2tracker-2.1.10.dist-info}/METADATA +7 -4
- cs2tracker-2.1.10.dist-info/RECORD +20 -0
- cs2tracker-2.1.8.dist-info/RECORD +0 -16
- {cs2tracker-2.1.8.dist-info → cs2tracker-2.1.10.dist-info}/WHEEL +0 -0
- {cs2tracker-2.1.8.dist-info → cs2tracker-2.1.10.dist-info}/entry_points.txt +0 -0
- {cs2tracker-2.1.8.dist-info → cs2tracker-2.1.10.dist-info}/licenses/LICENSE.md +0 -0
- {cs2tracker-2.1.8.dist-info → cs2tracker-2.1.10.dist-info}/top_level.txt +0 -0
cs2tracker/_version.py
CHANGED
cs2tracker/application.py
CHANGED
|
@@ -1,34 +1,40 @@
|
|
|
1
1
|
import ctypes
|
|
2
2
|
import tkinter as tk
|
|
3
|
+
from shutil import copy
|
|
3
4
|
from subprocess import Popen
|
|
4
5
|
from threading import Thread
|
|
6
|
+
from tkinter import messagebox, ttk
|
|
7
|
+
from tkinter.filedialog import askopenfilename, asksaveasfile
|
|
5
8
|
from typing import cast
|
|
6
9
|
|
|
7
10
|
import matplotlib.pyplot as plt
|
|
11
|
+
import sv_ttk
|
|
8
12
|
from matplotlib.axes import Axes
|
|
9
13
|
from matplotlib.dates import DateFormatter
|
|
10
14
|
|
|
15
|
+
from cs2tracker.background_task import BackgroundTask
|
|
11
16
|
from cs2tracker.constants import (
|
|
12
17
|
CONFIG_FILE,
|
|
18
|
+
CONFIG_FILE_BACKUP,
|
|
13
19
|
ICON_FILE,
|
|
14
20
|
OS,
|
|
15
21
|
OUTPUT_FILE,
|
|
22
|
+
POWERSHELL_COLORIZE_OUTPUT,
|
|
16
23
|
PYTHON_EXECUTABLE,
|
|
17
24
|
RUNNING_IN_EXE,
|
|
18
|
-
TEXT_EDITOR,
|
|
19
25
|
OSType,
|
|
20
26
|
)
|
|
27
|
+
from cs2tracker.price_logs import PriceLogs
|
|
21
28
|
from cs2tracker.scraper import Scraper
|
|
22
29
|
|
|
23
30
|
APPLICATION_NAME = "CS2Tracker"
|
|
31
|
+
WINDOW_SIZE = "630x380"
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
FONT_STYLE = "Segoe UI"
|
|
31
|
-
FONT_COLOR = "white"
|
|
33
|
+
CONFIG_EDITOR_TITLE = "Config Editor"
|
|
34
|
+
CONFIG_EDITOR_SIZE = "800x750"
|
|
35
|
+
|
|
36
|
+
NEW_CUSTOM_ITEM_TITLE = "Add Custom Item"
|
|
37
|
+
NEW_CUSTOM_ITEM_SIZE = "500x200"
|
|
32
38
|
|
|
33
39
|
SCRAPER_WINDOW_HEIGHT = 40
|
|
34
40
|
SCRAPER_WINDOW_WIDTH = 120
|
|
@@ -38,84 +44,64 @@ SCRAPER_WINDOW_BACKGROUND_COLOR = "Black"
|
|
|
38
44
|
class Application:
|
|
39
45
|
def __init__(self):
|
|
40
46
|
self.scraper = Scraper()
|
|
47
|
+
self.application_window = None
|
|
48
|
+
self.config_editor_window = None
|
|
41
49
|
|
|
42
50
|
def run(self):
|
|
43
51
|
"""Run the main application window with buttons for scraping prices, editing the
|
|
44
52
|
configuration, showing history in a chart, and editing the log file.
|
|
45
53
|
"""
|
|
46
|
-
application_window = self._configure_window()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
self.application_window = self._configure_window()
|
|
55
|
+
|
|
56
|
+
sv_ttk.use_dark_theme()
|
|
57
|
+
|
|
58
|
+
self.application_window.mainloop()
|
|
59
|
+
|
|
60
|
+
def _add_button(self, frame, text, command, row):
|
|
61
|
+
"""Create and style a button for the button frame."""
|
|
62
|
+
grid_pos = {"row": row, "column": 0, "sticky": "ew", "padx": 10, "pady": 10}
|
|
63
|
+
button = ttk.Button(frame, text=text, command=command)
|
|
64
|
+
button.grid(**grid_pos)
|
|
65
|
+
|
|
66
|
+
def _configure_button_frame(self, main_frame):
|
|
67
|
+
"""Configure the button frame of the application main frame."""
|
|
68
|
+
button_frame = ttk.Frame(main_frame, style="Card.TFrame", padding=15)
|
|
69
|
+
button_frame.columnconfigure(0, weight=1)
|
|
70
|
+
button_frame.grid(row=0, column=0, padx=10, pady=(7, 20), sticky="nsew")
|
|
71
|
+
|
|
72
|
+
self._add_button(button_frame, "Run!", self.scrape_prices, 0)
|
|
73
|
+
self._add_button(button_frame, "Edit Config", self._edit_config, 1)
|
|
74
|
+
self._add_button(button_frame, "Reset Config", self._reset_config, 2)
|
|
75
|
+
self._add_button(button_frame, "Show History", self._draw_plot, 3)
|
|
76
|
+
self._add_button(button_frame, "Export History", self._export_log_file, 4)
|
|
77
|
+
self._add_button(button_frame, "Import History", self._import_log_file, 5)
|
|
78
|
+
|
|
79
|
+
def _add_checkbox(
|
|
80
|
+
self, frame, text, variable, command, row
|
|
81
|
+
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
82
|
+
"""Create and style a checkbox for the checkbox frame."""
|
|
83
|
+
grid_pos = {"row": row, "column": 0, "sticky": "w", "padx": (10, 0), "pady": 5}
|
|
84
|
+
checkbox = ttk.Checkbutton(
|
|
65
85
|
frame,
|
|
66
86
|
text=text,
|
|
67
87
|
variable=variable,
|
|
68
88
|
command=command,
|
|
69
|
-
|
|
70
|
-
fg=FONT_COLOR,
|
|
71
|
-
selectcolor=BUTTON_COLOR,
|
|
72
|
-
activebackground=BACKGROUND_COLOR,
|
|
73
|
-
font=(FONT_STYLE, 10),
|
|
74
|
-
anchor="w",
|
|
75
|
-
padx=20,
|
|
76
|
-
)
|
|
77
|
-
checkbox.pack(fill="x", anchor="w", pady=2)
|
|
78
|
-
|
|
79
|
-
def _configure_window(self):
|
|
80
|
-
"""Configure the main application window UI and add buttons for the main
|
|
81
|
-
functionalities.
|
|
82
|
-
"""
|
|
83
|
-
window = tk.Tk()
|
|
84
|
-
window.title(APPLICATION_NAME)
|
|
85
|
-
window.geometry(WINDOW_SIZE)
|
|
86
|
-
window.configure(bg=BACKGROUND_COLOR)
|
|
87
|
-
if OS == OSType.WINDOWS:
|
|
88
|
-
app_id = "cs2tracker.unique.id"
|
|
89
|
-
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
90
|
-
icon = tk.PhotoImage(file=ICON_FILE)
|
|
91
|
-
window.wm_iconphoto(False, icon)
|
|
92
|
-
|
|
93
|
-
frame = tk.Frame(window, bg=BACKGROUND_COLOR, padx=30, pady=30)
|
|
94
|
-
frame.pack(expand=True, fill="both")
|
|
95
|
-
|
|
96
|
-
label = tk.Label(
|
|
97
|
-
frame,
|
|
98
|
-
text=f"Welcome to {APPLICATION_NAME}!",
|
|
99
|
-
font=(FONT_STYLE, 16, "bold"),
|
|
100
|
-
fg=FONT_COLOR,
|
|
101
|
-
bg=BACKGROUND_COLOR,
|
|
89
|
+
style="Switch.TCheckbutton",
|
|
102
90
|
)
|
|
103
|
-
|
|
91
|
+
checkbox.grid(**grid_pos)
|
|
104
92
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
def _configure_checkbox_frame(self, main_frame):
|
|
94
|
+
"""Configure the checkbox frame for background tasks and settings."""
|
|
95
|
+
checkbox_frame = ttk.LabelFrame(main_frame, text="Settings", padding=15)
|
|
96
|
+
checkbox_frame.grid(row=0, column=1, padx=10, pady=(0, 20), sticky="nsew")
|
|
109
97
|
|
|
110
|
-
|
|
111
|
-
checkbox_frame.pack(pady=(20, 0), fill="x")
|
|
112
|
-
|
|
113
|
-
background_checkbox_value = tk.BooleanVar(value=self.scraper.identify_background_task())
|
|
98
|
+
background_checkbox_value = tk.BooleanVar(value=BackgroundTask.identify())
|
|
114
99
|
self._add_checkbox(
|
|
115
100
|
checkbox_frame,
|
|
116
|
-
"
|
|
101
|
+
"Background Task",
|
|
117
102
|
background_checkbox_value,
|
|
118
103
|
lambda: self._toggle_background_task(background_checkbox_value.get()),
|
|
104
|
+
0,
|
|
119
105
|
)
|
|
120
106
|
|
|
121
107
|
discord_webhook_checkbox_value = tk.BooleanVar(
|
|
@@ -125,9 +111,12 @@ class Application:
|
|
|
125
111
|
)
|
|
126
112
|
self._add_checkbox(
|
|
127
113
|
checkbox_frame,
|
|
128
|
-
"
|
|
114
|
+
"Discord Notifications",
|
|
129
115
|
discord_webhook_checkbox_value,
|
|
130
|
-
lambda:
|
|
116
|
+
lambda: discord_webhook_checkbox_value.set(
|
|
117
|
+
self._toggle_discord_webhook(discord_webhook_checkbox_value.get())
|
|
118
|
+
),
|
|
119
|
+
1,
|
|
131
120
|
)
|
|
132
121
|
|
|
133
122
|
use_proxy_checkbox_value = tk.BooleanVar(
|
|
@@ -137,9 +126,48 @@ class Application:
|
|
|
137
126
|
checkbox_frame,
|
|
138
127
|
"Proxy Requests",
|
|
139
128
|
use_proxy_checkbox_value,
|
|
140
|
-
lambda:
|
|
129
|
+
lambda: use_proxy_checkbox_value.set(
|
|
130
|
+
self._toggle_use_proxy(use_proxy_checkbox_value.get())
|
|
131
|
+
),
|
|
132
|
+
2,
|
|
141
133
|
)
|
|
142
134
|
|
|
135
|
+
dark_theme_checkbox_value = tk.BooleanVar(value=True)
|
|
136
|
+
self._add_checkbox(
|
|
137
|
+
checkbox_frame, "Dark Theme", dark_theme_checkbox_value, sv_ttk.toggle_theme, 3
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _configure_main_frame(self, window):
|
|
141
|
+
"""Configure the main frame of the application window with buttons and
|
|
142
|
+
checkboxes.
|
|
143
|
+
"""
|
|
144
|
+
main_frame = ttk.Frame(window, padding=15)
|
|
145
|
+
main_frame.columnconfigure(0, weight=1)
|
|
146
|
+
main_frame.columnconfigure(1, weight=1)
|
|
147
|
+
main_frame.rowconfigure(0, weight=1)
|
|
148
|
+
|
|
149
|
+
self._configure_button_frame(main_frame)
|
|
150
|
+
self._configure_checkbox_frame(main_frame)
|
|
151
|
+
|
|
152
|
+
main_frame.pack(expand=True, fill="both")
|
|
153
|
+
|
|
154
|
+
def _configure_window(self):
|
|
155
|
+
"""Configure the main application window UI and add buttons for the main
|
|
156
|
+
functionalities.
|
|
157
|
+
"""
|
|
158
|
+
window = tk.Tk()
|
|
159
|
+
window.title(APPLICATION_NAME)
|
|
160
|
+
window.geometry(WINDOW_SIZE)
|
|
161
|
+
|
|
162
|
+
if OS == OSType.WINDOWS:
|
|
163
|
+
app_id = "cs2tracker.unique.id"
|
|
164
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
165
|
+
|
|
166
|
+
icon = tk.PhotoImage(file=ICON_FILE)
|
|
167
|
+
window.wm_iconphoto(True, icon)
|
|
168
|
+
|
|
169
|
+
self._configure_main_frame(window)
|
|
170
|
+
|
|
143
171
|
return window
|
|
144
172
|
|
|
145
173
|
def _construct_scraper_command_windows(self):
|
|
@@ -158,7 +186,7 @@ class Application:
|
|
|
158
186
|
|
|
159
187
|
if RUNNING_IN_EXE:
|
|
160
188
|
# The python executable is set as the executable itself in PyInstaller
|
|
161
|
-
scraper_cmd = f"{PYTHON_EXECUTABLE} --only-scrape |
|
|
189
|
+
scraper_cmd = f"{PYTHON_EXECUTABLE} --only-scrape | {POWERSHELL_COLORIZE_OUTPUT}"
|
|
162
190
|
else:
|
|
163
191
|
scraper_cmd = f"{PYTHON_EXECUTABLE} -m cs2tracker --only-scrape"
|
|
164
192
|
|
|
@@ -195,45 +223,280 @@ class Application:
|
|
|
195
223
|
# TODO: implement external window for Linux
|
|
196
224
|
self.scraper.scrape_prices()
|
|
197
225
|
|
|
226
|
+
def _make_tree_editable(self, editor_frame, tree):
|
|
227
|
+
"""
|
|
228
|
+
Add a binding to the treeview that allows double-clicking on a cell to edit its
|
|
229
|
+
value.
|
|
230
|
+
|
|
231
|
+
Source: https://stackoverflow.com/questions/75787251/create-an-editable-tkinter-treeview-with-keyword-connection
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def set_cell_value(event):
|
|
235
|
+
def save_edit(event):
|
|
236
|
+
tree.set(row, column=column, value=event.widget.get())
|
|
237
|
+
event.widget.destroy()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
row = tree.identify_row(event.y)
|
|
241
|
+
column = tree.identify_column(event.x)
|
|
242
|
+
item_text = tree.set(row, column)
|
|
243
|
+
if item_text.strip() == "":
|
|
244
|
+
left_item_text = tree.item(row, "text")
|
|
245
|
+
# Don't allow editing of section headers
|
|
246
|
+
if any(left_item_text == section for section in self.scraper.config.sections()):
|
|
247
|
+
return
|
|
248
|
+
x, y, w, h = tree.bbox(row, column)
|
|
249
|
+
entryedit = ttk.Entry(editor_frame)
|
|
250
|
+
entryedit.place(x=x, y=y, width=w, height=h + 3) # type: ignore
|
|
251
|
+
entryedit.insert("end", item_text)
|
|
252
|
+
entryedit.bind("<Return>", save_edit)
|
|
253
|
+
entryedit.focus_set()
|
|
254
|
+
entryedit.grab_set()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
def destroy_entries(_):
|
|
259
|
+
"""Destroy any entry widgets in the treeview when the mouse wheel is
|
|
260
|
+
used.
|
|
261
|
+
"""
|
|
262
|
+
for widget in editor_frame.winfo_children():
|
|
263
|
+
if isinstance(widget, ttk.Entry):
|
|
264
|
+
widget.destroy()
|
|
265
|
+
|
|
266
|
+
def destroy_entry(event):
|
|
267
|
+
"""Destroy the entry widget if the user clicks outside of it."""
|
|
268
|
+
if isinstance(event.widget, ttk.Entry):
|
|
269
|
+
event.widget.destroy()
|
|
270
|
+
|
|
271
|
+
tree.bind("<Double-1>", set_cell_value)
|
|
272
|
+
self.config_editor_window.bind("<MouseWheel>", destroy_entries) # type: ignore
|
|
273
|
+
self.config_editor_window.bind("<Button-1>", destroy_entry) # type: ignore
|
|
274
|
+
|
|
275
|
+
def _configure_treeview(self, editor_frame):
|
|
276
|
+
"""Add a treeview to the editor frame to display and edit configuration
|
|
277
|
+
options.
|
|
278
|
+
"""
|
|
279
|
+
scrollbar = ttk.Scrollbar(editor_frame)
|
|
280
|
+
scrollbar.pack(side="right", fill="y", padx=(5, 0))
|
|
281
|
+
|
|
282
|
+
tree = ttk.Treeview(
|
|
283
|
+
editor_frame,
|
|
284
|
+
columns=(1,),
|
|
285
|
+
height=10,
|
|
286
|
+
selectmode="browse",
|
|
287
|
+
yscrollcommand=scrollbar.set,
|
|
288
|
+
)
|
|
289
|
+
scrollbar.config(command=tree.yview)
|
|
290
|
+
|
|
291
|
+
tree.column("#0", anchor="w", width=200)
|
|
292
|
+
tree.column(1, anchor="w", width=25)
|
|
293
|
+
tree.heading("#0", text="Option")
|
|
294
|
+
tree.heading(1, text="Value")
|
|
295
|
+
|
|
296
|
+
for section in self.scraper.config.sections():
|
|
297
|
+
if section == "App Settings":
|
|
298
|
+
continue
|
|
299
|
+
section_level = tree.insert("", "end", iid=section, text=section)
|
|
300
|
+
for config_option, value in self.scraper.config.items(section):
|
|
301
|
+
title_option = config_option.replace("_", " ").title()
|
|
302
|
+
tree.insert(section_level, "end", text=title_option, values=(value,))
|
|
303
|
+
|
|
304
|
+
self._make_tree_editable(editor_frame, tree)
|
|
305
|
+
|
|
306
|
+
return tree
|
|
307
|
+
|
|
308
|
+
def _configure_save_button(self, button_frame, tree):
|
|
309
|
+
"""Save updated options and values from the treeview back to the config file."""
|
|
310
|
+
|
|
311
|
+
def save_config():
|
|
312
|
+
for child in tree.get_children():
|
|
313
|
+
for item in tree.get_children(child):
|
|
314
|
+
title_option = tree.item(item, "text")
|
|
315
|
+
config_option = title_option.lower().replace(" ", "_")
|
|
316
|
+
value = tree.item(item, "values")[0]
|
|
317
|
+
section = tree.parent(item)
|
|
318
|
+
section_name = tree.item(section, "text")
|
|
319
|
+
if section_name == "Custom Items":
|
|
320
|
+
# custom items are already saved upon creation (Saving them again would result in duplicates)
|
|
321
|
+
continue
|
|
322
|
+
self.scraper.config.set(section_name, config_option, value)
|
|
323
|
+
|
|
324
|
+
self.scraper.config.write_to_file()
|
|
325
|
+
if self.scraper.config.valid:
|
|
326
|
+
messagebox.showinfo(
|
|
327
|
+
"Config Saved", "The configuration has been saved successfully."
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
messagebox.showerror(
|
|
331
|
+
"Config Error",
|
|
332
|
+
f"The configuration is invalid. ({self.scraper.config.last_error})",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
save_button = ttk.Button(button_frame, text="Save", command=save_config)
|
|
336
|
+
save_button.pack(side="left", expand=True, padx=5)
|
|
337
|
+
|
|
338
|
+
def _configure_custom_item_button(self, button_frame, tree):
|
|
339
|
+
"""Add a button that opens an entry dialog to add a custom item to the
|
|
340
|
+
configuration.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def add_custom_item(item_url, item_owned):
|
|
344
|
+
"""Add a custom item to the configuration."""
|
|
345
|
+
if not item_url or not item_owned:
|
|
346
|
+
messagebox.showerror("Input Error", "All fields must be filled out.")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
if int(item_owned) < 0:
|
|
351
|
+
raise ValueError("Owned count must be a non-negative integer.")
|
|
352
|
+
except ValueError as error:
|
|
353
|
+
messagebox.showerror("Input Error", f"Invalid owned count: {error}")
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
self.scraper.config.set("Custom Items", item_url, item_owned)
|
|
357
|
+
self.scraper.config.write_to_file()
|
|
358
|
+
if self.scraper.config.valid:
|
|
359
|
+
tree.insert("Custom Items", "end", text=item_url, values=(item_owned,))
|
|
360
|
+
messagebox.showinfo("Custom Item Added", "Custom item has been added successfully.")
|
|
361
|
+
else:
|
|
362
|
+
self.scraper.config.remove_option("Custom Items", item_url)
|
|
363
|
+
messagebox.showerror(
|
|
364
|
+
"Config Error",
|
|
365
|
+
f"The configuration is invalid. ({self.scraper.config.last_error})",
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def open_custom_item_dialog():
|
|
369
|
+
"""Open a dialog to enter custom item details."""
|
|
370
|
+
dialog = tk.Toplevel(self.config_editor_window)
|
|
371
|
+
dialog.title(NEW_CUSTOM_ITEM_TITLE)
|
|
372
|
+
dialog.geometry(NEW_CUSTOM_ITEM_SIZE)
|
|
373
|
+
|
|
374
|
+
dialog_frame = ttk.Frame(dialog, padding=10)
|
|
375
|
+
dialog_frame.pack(expand=True, fill="both")
|
|
376
|
+
|
|
377
|
+
ttk.Label(dialog_frame, text="Item URL:").pack(pady=5)
|
|
378
|
+
item_url_entry = ttk.Entry(dialog_frame)
|
|
379
|
+
item_url_entry.pack(fill="x", padx=10)
|
|
380
|
+
|
|
381
|
+
ttk.Label(dialog_frame, text="Owned Count:").pack(pady=5)
|
|
382
|
+
item_owned_entry = ttk.Entry(dialog_frame)
|
|
383
|
+
item_owned_entry.pack(fill="x", padx=10)
|
|
384
|
+
|
|
385
|
+
add_button = ttk.Button(
|
|
386
|
+
dialog_frame,
|
|
387
|
+
text="Add",
|
|
388
|
+
command=lambda: add_custom_item(item_url_entry.get(), item_owned_entry.get()),
|
|
389
|
+
)
|
|
390
|
+
add_button.pack(pady=10)
|
|
391
|
+
|
|
392
|
+
custom_item_button = ttk.Button(
|
|
393
|
+
button_frame, text="Add Custom Item", command=open_custom_item_dialog
|
|
394
|
+
)
|
|
395
|
+
custom_item_button.pack(side="left", expand=True, padx=5)
|
|
396
|
+
|
|
397
|
+
def _configure_editor_frame(self):
|
|
398
|
+
"""Configure the main editor frame which displays the configuration options in a
|
|
399
|
+
structured way.
|
|
400
|
+
"""
|
|
401
|
+
editor_frame = ttk.Frame(self.config_editor_window, padding=30)
|
|
402
|
+
editor_frame.pack(expand=True, fill="both")
|
|
403
|
+
|
|
404
|
+
tree = self._configure_treeview(editor_frame)
|
|
405
|
+
tree.pack(expand=True, fill="both")
|
|
406
|
+
|
|
407
|
+
button_frame = ttk.Frame(editor_frame, padding=10)
|
|
408
|
+
|
|
409
|
+
self._configure_save_button(button_frame, tree)
|
|
410
|
+
self._configure_custom_item_button(button_frame, tree)
|
|
411
|
+
|
|
412
|
+
button_frame.pack(side="bottom", padx=10, pady=(0, 10))
|
|
413
|
+
|
|
198
414
|
def _edit_config(self):
|
|
199
|
-
"""
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
415
|
+
"""Open a new window with a config editor GUI."""
|
|
416
|
+
self.config_editor_window = tk.Toplevel(self.application_window)
|
|
417
|
+
self.config_editor_window.geometry(CONFIG_EDITOR_SIZE)
|
|
418
|
+
self.config_editor_window.title(CONFIG_EDITOR_TITLE)
|
|
419
|
+
|
|
420
|
+
self._configure_editor_frame()
|
|
421
|
+
|
|
422
|
+
def _reset_config(self):
|
|
423
|
+
"""Reset the configuration file to its default state."""
|
|
424
|
+
confirm = messagebox.askokcancel(
|
|
425
|
+
"Reset Config", "Are you sure you want to reset the configuration?"
|
|
203
426
|
)
|
|
427
|
+
if confirm:
|
|
428
|
+
copy(CONFIG_FILE_BACKUP, CONFIG_FILE)
|
|
429
|
+
self.scraper.load_config()
|
|
204
430
|
|
|
205
431
|
def _draw_plot(self):
|
|
206
432
|
"""Draw a plot of the scraped prices over time."""
|
|
207
|
-
dates,
|
|
433
|
+
dates, usd_prices, eur_prices = PriceLogs.read()
|
|
208
434
|
|
|
209
435
|
fig, ax_raw = plt.subplots(figsize=(10, 8), num="CS2Tracker Price History")
|
|
210
436
|
fig.suptitle("CS2Tracker Price History", fontsize=16)
|
|
211
437
|
fig.autofmt_xdate()
|
|
212
438
|
|
|
213
439
|
ax = cast(Axes, ax_raw)
|
|
214
|
-
ax.plot(dates,
|
|
215
|
-
ax.plot(dates,
|
|
440
|
+
ax.plot(dates, usd_prices, label="Dollars")
|
|
441
|
+
ax.plot(dates, eur_prices, label="Euros")
|
|
216
442
|
ax.legend()
|
|
217
|
-
date_formatter = DateFormatter("%
|
|
443
|
+
date_formatter = DateFormatter("%Y-%m-%d")
|
|
218
444
|
ax.xaxis.set_major_formatter(date_formatter)
|
|
219
445
|
|
|
220
446
|
plt.show()
|
|
221
447
|
|
|
222
|
-
def
|
|
223
|
-
"""
|
|
224
|
-
|
|
448
|
+
def _export_log_file(self):
|
|
449
|
+
"""Lets the user export the log file to a different location."""
|
|
450
|
+
export_path = asksaveasfile(
|
|
451
|
+
title="Export Log File",
|
|
452
|
+
defaultextension=".csv",
|
|
453
|
+
filetypes=[("CSV files", "*.csv")],
|
|
454
|
+
)
|
|
455
|
+
if export_path:
|
|
456
|
+
copy(OUTPUT_FILE, export_path.name)
|
|
457
|
+
|
|
458
|
+
def _import_log_file(self):
|
|
459
|
+
"""Lets the user import a log file from a different location."""
|
|
460
|
+
import_path = askopenfilename(
|
|
461
|
+
title="Import Log File",
|
|
462
|
+
defaultextension=".csv",
|
|
463
|
+
filetypes=[("CSV files", "*.csv")],
|
|
464
|
+
)
|
|
465
|
+
if not PriceLogs.validate_file(import_path):
|
|
466
|
+
return
|
|
467
|
+
copy(import_path, OUTPUT_FILE)
|
|
225
468
|
|
|
226
469
|
def _toggle_background_task(self, enabled: bool):
|
|
227
470
|
"""Toggle whether a daily price calculation should run in the background."""
|
|
228
|
-
|
|
471
|
+
BackgroundTask.toggle(enabled)
|
|
229
472
|
|
|
230
473
|
def _toggle_use_proxy(self, enabled: bool):
|
|
231
474
|
"""Toggle whether the scraper should use proxy servers for requests."""
|
|
475
|
+
proxy_api_key = self.scraper.config.get("User Settings", "proxy_api_key", fallback=None)
|
|
476
|
+
if not proxy_api_key and enabled:
|
|
477
|
+
messagebox.showerror(
|
|
478
|
+
"Config Error",
|
|
479
|
+
"You need to enter a valid crawlbase API key into the config file to use this feature.",
|
|
480
|
+
)
|
|
481
|
+
return False
|
|
482
|
+
|
|
232
483
|
self.scraper.toggle_use_proxy(enabled)
|
|
484
|
+
return True
|
|
233
485
|
|
|
234
486
|
def _toggle_discord_webhook(self, enabled: bool):
|
|
235
487
|
"""Toggle whether the scraper should send notifications to a Discord webhook."""
|
|
488
|
+
discord_webhook_url = self.scraper.config.get(
|
|
489
|
+
"User Settings", "discord_webhook_url", fallback=None
|
|
490
|
+
)
|
|
491
|
+
if not discord_webhook_url and enabled:
|
|
492
|
+
messagebox.showerror(
|
|
493
|
+
"Config Error",
|
|
494
|
+
"You need to enter a valid Discord webhook URL into the config file to use this feature.",
|
|
495
|
+
)
|
|
496
|
+
return False
|
|
497
|
+
|
|
236
498
|
self.scraper.toggle_discord_webhook(enabled)
|
|
499
|
+
return True
|
|
237
500
|
|
|
238
501
|
|
|
239
502
|
def _popen_and_call(popen_args, callback):
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from subprocess import DEVNULL, call
|
|
3
|
+
|
|
4
|
+
from cs2tracker.constants import (
|
|
5
|
+
BATCH_FILE,
|
|
6
|
+
OS,
|
|
7
|
+
PROJECT_DIR,
|
|
8
|
+
PYTHON_EXECUTABLE,
|
|
9
|
+
RUNNING_IN_EXE,
|
|
10
|
+
OSType,
|
|
11
|
+
)
|
|
12
|
+
from cs2tracker.padded_console import PaddedConsole
|
|
13
|
+
|
|
14
|
+
WIN_BACKGROUND_TASK_NAME = "CS2Tracker Daily Calculation"
|
|
15
|
+
WIN_BACKGROUND_TASK_SCHEDULE = "DAILY"
|
|
16
|
+
WIN_BACKGROUND_TASK_TIME = "12:00"
|
|
17
|
+
WIN_BACKGROUND_TASK_CMD = (
|
|
18
|
+
f"powershell -WindowStyle Hidden -Command \"Start-Process '{BATCH_FILE}' -WindowStyle Hidden\""
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
console = PaddedConsole()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackgroundTask:
|
|
25
|
+
@classmethod
|
|
26
|
+
def identify(cls):
|
|
27
|
+
"""
|
|
28
|
+
Search the OS for a daily background task that runs the scraper.
|
|
29
|
+
|
|
30
|
+
:return: True if a background task is found, False otherwise.
|
|
31
|
+
"""
|
|
32
|
+
if OS == OSType.WINDOWS:
|
|
33
|
+
cmd = ["schtasks", "/query", "/tn", WIN_BACKGROUND_TASK_NAME]
|
|
34
|
+
return_code = call(cmd, stdout=DEVNULL, stderr=DEVNULL)
|
|
35
|
+
found = return_code == 0
|
|
36
|
+
return found
|
|
37
|
+
else:
|
|
38
|
+
# TODO: implement finder for cron jobs
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _toggle_batch_file(cls, enabled: bool):
|
|
43
|
+
"""
|
|
44
|
+
Create or delete a batch file that runs the scraper.
|
|
45
|
+
|
|
46
|
+
:param enabled: If True, the batch file will be created; if False, the batch
|
|
47
|
+
file will be deleted.
|
|
48
|
+
"""
|
|
49
|
+
if enabled:
|
|
50
|
+
with open(BATCH_FILE, "w", encoding="utf-8") as batch_file:
|
|
51
|
+
if RUNNING_IN_EXE:
|
|
52
|
+
# The python executable is set to the executable itself
|
|
53
|
+
# for executables created with PyInstaller
|
|
54
|
+
batch_file.write(f"{PYTHON_EXECUTABLE} --only-scrape\n")
|
|
55
|
+
else:
|
|
56
|
+
batch_file.write(f"cd {PROJECT_DIR}\n")
|
|
57
|
+
batch_file.write(f"{PYTHON_EXECUTABLE} -m cs2tracker --only-scrape\n")
|
|
58
|
+
else:
|
|
59
|
+
if os.path.exists(BATCH_FILE):
|
|
60
|
+
os.remove(BATCH_FILE)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _toggle_windows(cls, enabled: bool):
|
|
64
|
+
"""
|
|
65
|
+
Create or delete a daily background task that runs the scraper on Windows.
|
|
66
|
+
|
|
67
|
+
:param enabled: If True, the task will be created; if False, the task will be
|
|
68
|
+
deleted.
|
|
69
|
+
"""
|
|
70
|
+
cls._toggle_batch_file(enabled)
|
|
71
|
+
if enabled:
|
|
72
|
+
cmd = [
|
|
73
|
+
"schtasks",
|
|
74
|
+
"/create",
|
|
75
|
+
"/tn",
|
|
76
|
+
WIN_BACKGROUND_TASK_NAME,
|
|
77
|
+
"/tr",
|
|
78
|
+
WIN_BACKGROUND_TASK_CMD,
|
|
79
|
+
"/sc",
|
|
80
|
+
WIN_BACKGROUND_TASK_SCHEDULE,
|
|
81
|
+
"/st",
|
|
82
|
+
WIN_BACKGROUND_TASK_TIME,
|
|
83
|
+
]
|
|
84
|
+
return_code = call(cmd, stdout=DEVNULL, stderr=DEVNULL)
|
|
85
|
+
if return_code == 0:
|
|
86
|
+
console.print("[bold green][+] Background task enabled.")
|
|
87
|
+
else:
|
|
88
|
+
console.print("[bold red][!] Failed to enable background task.")
|
|
89
|
+
else:
|
|
90
|
+
cmd = ["schtasks", "/delete", "/tn", WIN_BACKGROUND_TASK_NAME, "/f"]
|
|
91
|
+
return_code = call(cmd, stdout=DEVNULL, stderr=DEVNULL)
|
|
92
|
+
if return_code == 0:
|
|
93
|
+
console.print("[bold green][-] Background task disabled.")
|
|
94
|
+
else:
|
|
95
|
+
console.print("[bold red][!] Failed to disable background task.")
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def toggle(cls, enabled: bool):
|
|
99
|
+
"""
|
|
100
|
+
Create or delete a daily background task that runs the scraper.
|
|
101
|
+
|
|
102
|
+
:param enabled: If True, the task will be created; if False, the task will be
|
|
103
|
+
deleted.
|
|
104
|
+
"""
|
|
105
|
+
if OS == OSType.WINDOWS:
|
|
106
|
+
cls._toggle_windows(enabled)
|
|
107
|
+
else:
|
|
108
|
+
# TODO: implement toggle for cron jobs
|
|
109
|
+
pass
|