rhul-attendance-bot 0.1.48__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.
- RHUL_attendance_bot.py +580 -0
- app_paths.py +74 -0
- auto_login.py +739 -0
- discord_broadcast.py +70 -0
- display_manager.py +274 -0
- fetch_ics.py +103 -0
- local_2fa.py +31 -0
- rhul_attendance_bot-0.1.48.dist-info/METADATA +234 -0
- rhul_attendance_bot-0.1.48.dist-info/RECORD +14 -0
- rhul_attendance_bot-0.1.48.dist-info/WHEEL +5 -0
- rhul_attendance_bot-0.1.48.dist-info/entry_points.txt +2 -0
- rhul_attendance_bot-0.1.48.dist-info/licenses/LICENSE +22 -0
- rhul_attendance_bot-0.1.48.dist-info/top_level.txt +8 -0
- update.py +67 -0
discord_broadcast.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
DEFAULT_TIMEOUT = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DiscordBroadcaster:
|
|
12
|
+
"""Lightweight Discord webhook broadcaster with profile prefixing."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, credentials_path='credentials.json', logger=None, profile_name=None):
|
|
15
|
+
self.logger = logger or logging.getLogger("attendance_bot")
|
|
16
|
+
self.credentials_path = credentials_path
|
|
17
|
+
self.webhook_url = None
|
|
18
|
+
self.enabled = False
|
|
19
|
+
self.profile_name = profile_name or "Unknown"
|
|
20
|
+
self._load_settings()
|
|
21
|
+
|
|
22
|
+
def _load_settings(self):
|
|
23
|
+
try:
|
|
24
|
+
with open(self.credentials_path, 'r') as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
self.webhook_url = data.get('discord_webhook_url')
|
|
27
|
+
if not self.profile_name and data.get('profile_nickname'):
|
|
28
|
+
self.profile_name = data.get('profile_nickname')
|
|
29
|
+
enabled_flag = data.get('enable_discord_webhook', True)
|
|
30
|
+
self.enabled = bool(self.webhook_url) and bool(enabled_flag)
|
|
31
|
+
except Exception:
|
|
32
|
+
self.enabled = False
|
|
33
|
+
self.webhook_url = None
|
|
34
|
+
|
|
35
|
+
def _send(self, content):
|
|
36
|
+
if not self.enabled or not self.webhook_url:
|
|
37
|
+
return False
|
|
38
|
+
message = content
|
|
39
|
+
if self.profile_name:
|
|
40
|
+
message = f"[{self.profile_name}] {content}"
|
|
41
|
+
try:
|
|
42
|
+
resp = requests.post(self.webhook_url, json={"content": message}, timeout=DEFAULT_TIMEOUT)
|
|
43
|
+
if resp.status_code >= 400:
|
|
44
|
+
self.logger.warning(f"Discord webhook failed: {resp.status_code} {resp.text}")
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
except Exception as e:
|
|
48
|
+
self.logger.warning(f"Discord webhook error: {e}")
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def notify_bot_started(self, version_label=None):
|
|
52
|
+
suffix = f" ({version_label})" if version_label else ""
|
|
53
|
+
return self._send(f"🚀 Bot started{suffix}")
|
|
54
|
+
|
|
55
|
+
def notify_bot_stopped(self, runtime=None):
|
|
56
|
+
suffix = f" (runtime {runtime})" if runtime else ""
|
|
57
|
+
return self._send(f"🛑 Bot stopped{suffix}")
|
|
58
|
+
|
|
59
|
+
def notify_renew_login_success(self):
|
|
60
|
+
return self._send("✅ renew_login succeeded")
|
|
61
|
+
|
|
62
|
+
def notify_attendance_success(self, event_name, event_time=None):
|
|
63
|
+
when = ""
|
|
64
|
+
if isinstance(event_time, datetime):
|
|
65
|
+
try:
|
|
66
|
+
local_time = event_time.astimezone()
|
|
67
|
+
when = f" at {local_time.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
return self._send(f"✅ Attendance marked: {event_name}{when}")
|
display_manager.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import colorsys
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from collections import deque
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import termios
|
|
11
|
+
except ImportError: # pragma: no cover - non-POSIX
|
|
12
|
+
termios = None
|
|
13
|
+
|
|
14
|
+
from rich.align import Align
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from pynput import mouse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BufferLogHandler(logging.Handler):
|
|
24
|
+
def __init__(self, buffer, buffer_lock):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.buffer = buffer
|
|
27
|
+
self.buffer_lock = buffer_lock
|
|
28
|
+
|
|
29
|
+
def emit(self, record):
|
|
30
|
+
log_entry = self.format(record)
|
|
31
|
+
with self.buffer_lock:
|
|
32
|
+
ts = datetime.now().strftime("%m/%d %H:%M:%S")
|
|
33
|
+
prefix = f"[yellow][{ts}][/yellow] "
|
|
34
|
+
# Add color to log levels (gray override for background noise)
|
|
35
|
+
if getattr(record, "gray", False):
|
|
36
|
+
log_entry = f"[bright_black]{log_entry}[/bright_black]"
|
|
37
|
+
elif record.levelno == logging.INFO:
|
|
38
|
+
log_entry = f"[green]{log_entry}[/green]"
|
|
39
|
+
elif record.levelno == logging.WARNING:
|
|
40
|
+
log_entry = f"[yellow]{log_entry}[/yellow]"
|
|
41
|
+
elif record.levelno == logging.ERROR:
|
|
42
|
+
log_entry = f"[red]{log_entry}[/red]"
|
|
43
|
+
elif record.levelno == logging.DEBUG:
|
|
44
|
+
log_entry = f"[blue]{log_entry}[/blue]"
|
|
45
|
+
self.buffer.append(prefix + log_entry)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DisplayManager:
|
|
49
|
+
"""Render the Rich dashboard in its own loop."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
console: Console,
|
|
54
|
+
profile_nickname,
|
|
55
|
+
broadcaster,
|
|
56
|
+
get_git_info,
|
|
57
|
+
get_runtime_duration,
|
|
58
|
+
get_attendance_count,
|
|
59
|
+
log_window=7,
|
|
60
|
+
):
|
|
61
|
+
self.console = console
|
|
62
|
+
self.profile_nickname = profile_nickname
|
|
63
|
+
self.broadcaster = broadcaster
|
|
64
|
+
self.get_git_info = get_git_info
|
|
65
|
+
self.get_runtime_duration = get_runtime_duration
|
|
66
|
+
self.get_attendance_count = get_attendance_count
|
|
67
|
+
self.log_window = log_window
|
|
68
|
+
self.log_buffer = deque(maxlen=200)
|
|
69
|
+
self.log_buffer_lock = threading.Lock()
|
|
70
|
+
self.log_scroll_offset = 0
|
|
71
|
+
self.log_scroll_lock = threading.Lock()
|
|
72
|
+
self.refresh_event = threading.Event()
|
|
73
|
+
self._buffer_handler = BufferLogHandler(self.log_buffer, self.log_buffer_lock)
|
|
74
|
+
self._stdin_fd = None
|
|
75
|
+
self._old_term_attrs = None
|
|
76
|
+
|
|
77
|
+
def get_log_handler(self):
|
|
78
|
+
return self._buffer_handler
|
|
79
|
+
|
|
80
|
+
def adjust_log_scroll(self, delta):
|
|
81
|
+
with self.log_buffer_lock, self.log_scroll_lock:
|
|
82
|
+
max_offset = max(len(self.log_buffer) - self.log_window, 0)
|
|
83
|
+
self.log_scroll_offset = min(max(self.log_scroll_offset + delta, 0), max_offset)
|
|
84
|
+
self.refresh_event.set()
|
|
85
|
+
|
|
86
|
+
def get_log_state(self):
|
|
87
|
+
with self.log_buffer_lock, self.log_scroll_lock:
|
|
88
|
+
n = len(self.log_buffer)
|
|
89
|
+
offset = min(self.log_scroll_offset, max(n - self.log_window, 0))
|
|
90
|
+
if n <= self.log_window:
|
|
91
|
+
lines = list(self.log_buffer)
|
|
92
|
+
else:
|
|
93
|
+
bottom = max(self.log_window, min(n, n - offset))
|
|
94
|
+
start = bottom - self.log_window
|
|
95
|
+
end = bottom
|
|
96
|
+
lines = list(self.log_buffer)[start:end]
|
|
97
|
+
return {
|
|
98
|
+
"lines": lines,
|
|
99
|
+
"total": n,
|
|
100
|
+
"offset": offset,
|
|
101
|
+
"window": self.log_window,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def listen_for_scroll(self, exit_event):
|
|
105
|
+
def on_scroll(x, y, dx, dy):
|
|
106
|
+
try:
|
|
107
|
+
if dy > 0:
|
|
108
|
+
self.adjust_log_scroll(1)
|
|
109
|
+
elif dy < 0:
|
|
110
|
+
self.adjust_log_scroll(-1)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
listener = mouse.Listener(on_scroll=on_scroll, suppress=False)
|
|
115
|
+
listener.start()
|
|
116
|
+
while not exit_event.is_set():
|
|
117
|
+
time.sleep(0.2)
|
|
118
|
+
listener.stop()
|
|
119
|
+
|
|
120
|
+
def _nickname_color(self, t=None, speed=0.05):
|
|
121
|
+
"""Return a smoothly changing hex color for the profile label."""
|
|
122
|
+
if t is None:
|
|
123
|
+
t = time.time()
|
|
124
|
+
hue = (t * speed) % 1.0
|
|
125
|
+
r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
|
126
|
+
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
|
|
127
|
+
|
|
128
|
+
def _enter_noecho(self):
|
|
129
|
+
if termios is None:
|
|
130
|
+
return
|
|
131
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
fd = sys.stdin.fileno()
|
|
135
|
+
attrs = termios.tcgetattr(fd)
|
|
136
|
+
new_attrs = termios.tcgetattr(fd)
|
|
137
|
+
new_attrs[3] = new_attrs[3] & ~termios.ECHO
|
|
138
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
|
139
|
+
self._stdin_fd = fd
|
|
140
|
+
self._old_term_attrs = attrs
|
|
141
|
+
except Exception:
|
|
142
|
+
self._stdin_fd = None
|
|
143
|
+
self._old_term_attrs = None
|
|
144
|
+
|
|
145
|
+
def _restore_noecho(self):
|
|
146
|
+
if termios is None:
|
|
147
|
+
return
|
|
148
|
+
if self._stdin_fd is None or self._old_term_attrs is None:
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._old_term_attrs)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
self._stdin_fd = None
|
|
155
|
+
self._old_term_attrs = None
|
|
156
|
+
|
|
157
|
+
def run(self, exit_event):
|
|
158
|
+
git_commit, git_date, git_count = self.get_git_info()
|
|
159
|
+
try:
|
|
160
|
+
git_count_display = str(int(git_count) - 1)
|
|
161
|
+
except Exception:
|
|
162
|
+
git_count_display = git_count
|
|
163
|
+
|
|
164
|
+
self._enter_noecho()
|
|
165
|
+
try:
|
|
166
|
+
with Live(refresh_per_second=60, console=self.console, screen=True) as live:
|
|
167
|
+
while not exit_event.is_set():
|
|
168
|
+
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
169
|
+
runtime = self.get_runtime_duration()
|
|
170
|
+
attendance = self.get_attendance_count()
|
|
171
|
+
log_state = self.get_log_state()
|
|
172
|
+
latest_logs = log_state.get("lines", [])
|
|
173
|
+
total_logs = log_state.get("total", 0)
|
|
174
|
+
offset = log_state.get("offset", 0)
|
|
175
|
+
window = log_state.get("window", 7)
|
|
176
|
+
|
|
177
|
+
broadcast_enabled = False
|
|
178
|
+
try:
|
|
179
|
+
broadcast_enabled = bool(getattr(self.broadcaster, "enabled", False))
|
|
180
|
+
except Exception:
|
|
181
|
+
broadcast_enabled = False
|
|
182
|
+
|
|
183
|
+
info_table = Table.grid(expand=True)
|
|
184
|
+
info_table.add_row(
|
|
185
|
+
f"[bold cyan]Current Time:[/bold cyan] {current_time}",
|
|
186
|
+
f"[bold green]Runtime Duration:[/bold green] {runtime}",
|
|
187
|
+
f"[bold yellow]Attendance Success Count:[/bold yellow] {attendance}",
|
|
188
|
+
f"[bold magenta]PandaQuQ:[/bold magenta] [link=https://github.com/PandaQuQ/RHUL_attendance_bot]GitHub Repo[/link]",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
log_content = "\n".join(latest_logs) if latest_logs else "No logs available."
|
|
192
|
+
|
|
193
|
+
logs_table = Table.grid(expand=True)
|
|
194
|
+
if total_logs <= window:
|
|
195
|
+
logs_table.add_column(ratio=1)
|
|
196
|
+
logs_table.add_row(
|
|
197
|
+
Panel(
|
|
198
|
+
log_content,
|
|
199
|
+
title="[bold green]Latest Logs[/bold green]",
|
|
200
|
+
border_style="green",
|
|
201
|
+
padding=(1, 2),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
scrollbar_lines = []
|
|
206
|
+
thumb_size = max(1, int(window * window / total_logs))
|
|
207
|
+
track_size = window
|
|
208
|
+
max_thumb_pos = max(track_size - thumb_size, 0)
|
|
209
|
+
thumb_pos = max_thumb_pos - int((offset / max(total_logs - window, 1)) * max_thumb_pos)
|
|
210
|
+
for i in range(track_size):
|
|
211
|
+
if thumb_pos <= i < thumb_pos + thumb_size:
|
|
212
|
+
scrollbar_lines.append("â–ˆ")
|
|
213
|
+
else:
|
|
214
|
+
scrollbar_lines.append("│")
|
|
215
|
+
|
|
216
|
+
scrollbar_text = Text("\n".join(scrollbar_lines))
|
|
217
|
+
|
|
218
|
+
logs_table.add_column(ratio=30)
|
|
219
|
+
logs_table.add_column(ratio=1, width=1)
|
|
220
|
+
logs_table.add_row(
|
|
221
|
+
Panel(
|
|
222
|
+
log_content,
|
|
223
|
+
title="[bold green]Latest Logs[/bold green]",
|
|
224
|
+
border_style="green",
|
|
225
|
+
padding=(1, 2),
|
|
226
|
+
),
|
|
227
|
+
Panel(scrollbar_text, border_style="green", padding=(1, 0)),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
instructions = Text.from_markup(
|
|
231
|
+
"Press [yellow][[/yellow] then [yellow]][/yellow] to manually trigger the next event\n"
|
|
232
|
+
"Press [yellow][[/yellow] then [yellow]q[/yellow] to exit the script",
|
|
233
|
+
justify="center",
|
|
234
|
+
)
|
|
235
|
+
shortcut_instructions = Align.center(instructions, vertical="middle")
|
|
236
|
+
|
|
237
|
+
version_number_text = Align.center(
|
|
238
|
+
f"This is the No.[red]{git_count_display}[/red] version", vertical="middle"
|
|
239
|
+
)
|
|
240
|
+
nick_color = self._nickname_color()
|
|
241
|
+
nickname_text = Align.center(
|
|
242
|
+
f"[bold]{'[white]Profile:[/]'} [{nick_color}]{self.profile_nickname}[/]", vertical="middle"
|
|
243
|
+
)
|
|
244
|
+
broadcast_status = (
|
|
245
|
+
"[green]Enabled[/green]" if broadcast_enabled else "[red]Disabled[/red]"
|
|
246
|
+
)
|
|
247
|
+
broadcast_text = Align.center(
|
|
248
|
+
f"Discord Broadcast: {broadcast_status}", vertical="middle"
|
|
249
|
+
)
|
|
250
|
+
version_text = Align.center(
|
|
251
|
+
f"[bright_black]Commit: {git_commit} ({git_date})[/bright_black]",
|
|
252
|
+
vertical="middle",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
layout = Table.grid(expand=True)
|
|
256
|
+
layout.add_row(info_table)
|
|
257
|
+
layout.add_row(logs_table)
|
|
258
|
+
layout.add_row(nickname_text)
|
|
259
|
+
layout.add_row(broadcast_text)
|
|
260
|
+
layout.add_row(shortcut_instructions)
|
|
261
|
+
layout.add_row(version_text)
|
|
262
|
+
layout.add_row(version_number_text)
|
|
263
|
+
|
|
264
|
+
live.update(layout)
|
|
265
|
+
|
|
266
|
+
if exit_event.wait(timeout=0):
|
|
267
|
+
break
|
|
268
|
+
# Wake early on scroll; otherwise short sleep to keep CPU low
|
|
269
|
+
if self.refresh_event.wait(timeout=0.02):
|
|
270
|
+
self.refresh_event.clear()
|
|
271
|
+
continue
|
|
272
|
+
time.sleep(0.02)
|
|
273
|
+
finally:
|
|
274
|
+
self._restore_noecho()
|
fetch_ics.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from selenium import webdriver
|
|
5
|
+
from selenium.webdriver.chrome.service import Service
|
|
6
|
+
from selenium.webdriver.common.by import By
|
|
7
|
+
from selenium.webdriver.support.ui import WebDriverWait, Select
|
|
8
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
9
|
+
from selenium.webdriver.chrome.options import Options
|
|
10
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
11
|
+
from app_paths import get_credentials_path, get_ics_dir, prompt_select_profile
|
|
12
|
+
TIMETABLE_URL = 'https://webtimetables.royalholloway.ac.uk/'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_credentials(profile_name=None):
|
|
16
|
+
credentials_path = get_credentials_path(profile_name)
|
|
17
|
+
if not os.path.exists(credentials_path):
|
|
18
|
+
raise RuntimeError('Missing credentials.json')
|
|
19
|
+
with open(credentials_path, 'r') as f:
|
|
20
|
+
return json.load(f)
|
|
21
|
+
|
|
22
|
+
def start_driver():
|
|
23
|
+
options = Options()
|
|
24
|
+
options.add_argument('--no-sandbox')
|
|
25
|
+
options.add_argument('--disable-dev-shm-usage')
|
|
26
|
+
options.add_experimental_option('excludeSwitches', ['enable-logging'])
|
|
27
|
+
service = Service(ChromeDriverManager().install())
|
|
28
|
+
driver = webdriver.Chrome(service=service, options=options)
|
|
29
|
+
return driver
|
|
30
|
+
|
|
31
|
+
def fetch_ics_url(profile_name=None):
|
|
32
|
+
creds = load_credentials(profile_name)
|
|
33
|
+
username = creds['username'].split('@')[0]
|
|
34
|
+
password = creds['password']
|
|
35
|
+
driver = start_driver()
|
|
36
|
+
driver.get(TIMETABLE_URL)
|
|
37
|
+
try:
|
|
38
|
+
# Login
|
|
39
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.ID, 'tUserName')))
|
|
40
|
+
driver.find_element(By.ID, 'tUserName').send_keys(username)
|
|
41
|
+
driver.find_element(By.ID, 'tPassword').send_keys(password)
|
|
42
|
+
driver.find_element(By.ID, 'bLogin').click()
|
|
43
|
+
time.sleep(2)
|
|
44
|
+
# Click My Timetable
|
|
45
|
+
WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'LinkBtn_studentMyTimetable')))
|
|
46
|
+
driver.find_element(By.ID, 'LinkBtn_studentMyTimetable').click()
|
|
47
|
+
time.sleep(1)
|
|
48
|
+
# Select weeks: Autumn, Spring & Summer Term
|
|
49
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.ID, 'lbWeeks')))
|
|
50
|
+
select_weeks = Select(driver.find_element(By.ID, 'lbWeeks'))
|
|
51
|
+
select_weeks.select_by_value('2;3;4;5;6;7;8;9;10;11;12;18;19;20;21;22;23;24;25;26;27;28;33;34;35;36;37;38')
|
|
52
|
+
time.sleep(1)
|
|
53
|
+
# Select iCal radio
|
|
54
|
+
WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'RadioType_2')))
|
|
55
|
+
driver.find_element(By.ID, 'RadioType_2').click()
|
|
56
|
+
time.sleep(1)
|
|
57
|
+
# Click View Timetable
|
|
58
|
+
WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'bGetTimetable')))
|
|
59
|
+
driver.find_element(By.ID, 'bGetTimetable').click()
|
|
60
|
+
time.sleep(1)
|
|
61
|
+
# Click Android link
|
|
62
|
+
WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'android')))
|
|
63
|
+
driver.find_element(By.ID, 'android').click()
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
# Extract iCal URL
|
|
66
|
+
ical_url = None
|
|
67
|
+
strongs = driver.find_elements(By.TAG_NAME, 'strong')
|
|
68
|
+
for s in strongs:
|
|
69
|
+
text = s.text.strip()
|
|
70
|
+
if text.startswith('https://webtimetables.royalholloway.ac.uk/ical/default.aspx?'):
|
|
71
|
+
ical_url = text
|
|
72
|
+
break
|
|
73
|
+
if ical_url:
|
|
74
|
+
print(f'iCal URL: {ical_url}')
|
|
75
|
+
# Download the .ics file
|
|
76
|
+
import requests
|
|
77
|
+
# Campus site has broken/unknown cert; disable verification for this download.
|
|
78
|
+
requests.packages.urllib3.disable_warnings() # suppress InsecureRequestWarning
|
|
79
|
+
response = requests.get(ical_url, verify=False)
|
|
80
|
+
if response.status_code == 200:
|
|
81
|
+
ics_folder = get_ics_dir(profile_name)
|
|
82
|
+
ics_path = os.path.join(ics_folder, 'student_timetable.ics')
|
|
83
|
+
with open(ics_path, 'wb') as f:
|
|
84
|
+
f.write(response.content)
|
|
85
|
+
print(f'.ics file saved to {ics_path}')
|
|
86
|
+
else:
|
|
87
|
+
print(f'Failed to download .ics file. Status code: {response.status_code}')
|
|
88
|
+
else:
|
|
89
|
+
print('Could not find iCal URL on the page.')
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f'Error during timetable automation: {e}')
|
|
92
|
+
finally:
|
|
93
|
+
time.sleep(5)
|
|
94
|
+
driver.quit()
|
|
95
|
+
|
|
96
|
+
if __name__ == '__main__':
|
|
97
|
+
import argparse
|
|
98
|
+
|
|
99
|
+
parser = argparse.ArgumentParser(description="RHUL timetable ICS fetcher")
|
|
100
|
+
parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
profile_name = args.profile if args.profile else prompt_select_profile()
|
|
103
|
+
fetch_ics_url(profile_name=profile_name)
|
local_2fa.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pyotp
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
CONFIG_FILE = '2fa_config.json'
|
|
7
|
+
|
|
8
|
+
def bind(secret: str):
|
|
9
|
+
"""Bind the Microsoft Authenticator secret (Base32 string)."""
|
|
10
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
11
|
+
json.dump({'secret': secret}, f)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_secret():
|
|
15
|
+
if not os.path.exists(CONFIG_FILE):
|
|
16
|
+
return None
|
|
17
|
+
with open(CONFIG_FILE, 'r') as f:
|
|
18
|
+
return json.load(f).get('secret')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_otp():
|
|
22
|
+
"""Return the current OTP for the bound secret."""
|
|
23
|
+
secret = load_secret()
|
|
24
|
+
if not secret:
|
|
25
|
+
raise ValueError('No secret bound. Please bind first.')
|
|
26
|
+
totp = pyotp.TOTP(secret)
|
|
27
|
+
return totp.now()
|
|
28
|
+
|
|
29
|
+
# Example usage:
|
|
30
|
+
# bind('YOUR_BASE32_SECRET')
|
|
31
|
+
# print(get_otp())
|