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
auto_login.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
from selenium import webdriver
|
|
8
|
+
from selenium.webdriver.chrome.service import Service
|
|
9
|
+
from selenium.webdriver.common.by import By
|
|
10
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
11
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
12
|
+
from selenium.webdriver.chrome.options import Options
|
|
13
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
14
|
+
from local_2fa import bind, get_otp
|
|
15
|
+
from app_paths import get_2fa_config_path, get_credentials_path, prompt_select_profile
|
|
16
|
+
SECURITY_INFO_URL = 'https://mysignins.microsoft.com/security-info'
|
|
17
|
+
LOGIN_URL = 'https://mysignins.microsoft.com/security-info'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_config(username, password, secret, profile_nickname=None, discord_webhook_url=None, enable_discord_webhook=None, profile_name=None):
|
|
21
|
+
config_path = get_2fa_config_path(profile_name)
|
|
22
|
+
credentials_path = get_credentials_path(profile_name)
|
|
23
|
+
with open(config_path, 'w') as f:
|
|
24
|
+
json.dump({'secret': secret}, f)
|
|
25
|
+
payload = {'username': username, 'password': password}
|
|
26
|
+
if profile_nickname:
|
|
27
|
+
payload['profile_nickname'] = profile_nickname
|
|
28
|
+
if discord_webhook_url is not None:
|
|
29
|
+
payload['discord_webhook_url'] = discord_webhook_url
|
|
30
|
+
payload['enable_discord_webhook'] = bool(discord_webhook_url) if enable_discord_webhook is None else bool(enable_discord_webhook)
|
|
31
|
+
try:
|
|
32
|
+
# Preserve any existing fields
|
|
33
|
+
with open(credentials_path, 'r') as f:
|
|
34
|
+
existing = json.load(f)
|
|
35
|
+
existing.update(payload)
|
|
36
|
+
payload = existing
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
with open(credentials_path, 'w') as f:
|
|
40
|
+
json.dump(payload, f)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config(profile_name=None):
|
|
44
|
+
config_path = get_2fa_config_path(profile_name)
|
|
45
|
+
if not os.path.exists(config_path):
|
|
46
|
+
return None
|
|
47
|
+
with open(config_path, 'r') as f:
|
|
48
|
+
return json.load(f)
|
|
49
|
+
|
|
50
|
+
def load_credentials(profile_name=None):
|
|
51
|
+
credentials_path = get_credentials_path(profile_name)
|
|
52
|
+
if not os.path.exists(credentials_path):
|
|
53
|
+
return None
|
|
54
|
+
with open(credentials_path, 'r') as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def click_by_xpath_contains_text(driver, text, timeout=10):
|
|
59
|
+
xpath = f"//*[self::button or self::a or self::div or self::span][contains(normalize-space(.), '{text}') ]"
|
|
60
|
+
try:
|
|
61
|
+
elem = WebDriverWait(driver, timeout).until(
|
|
62
|
+
EC.element_to_be_clickable((By.XPATH, xpath))
|
|
63
|
+
)
|
|
64
|
+
driver.execute_script("arguments[0].click();", elem)
|
|
65
|
+
print(f"Clicked element with text contains: {text}")
|
|
66
|
+
return True
|
|
67
|
+
except Exception:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def maybe_switch_to_login_iframe(driver):
|
|
72
|
+
"""If login fields are inside an iframe, switch into it."""
|
|
73
|
+
try:
|
|
74
|
+
driver.switch_to.default_content()
|
|
75
|
+
frames = driver.find_elements(By.TAG_NAME, 'iframe')
|
|
76
|
+
for frame in frames:
|
|
77
|
+
try:
|
|
78
|
+
driver.switch_to.frame(frame)
|
|
79
|
+
if driver.find_elements(By.NAME, 'loginfmt') or driver.find_elements(By.ID, 'i0116'):
|
|
80
|
+
return True
|
|
81
|
+
except Exception:
|
|
82
|
+
driver.switch_to.default_content()
|
|
83
|
+
driver.switch_to.default_content()
|
|
84
|
+
except Exception:
|
|
85
|
+
driver.switch_to.default_content()
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def click_with_retries(driver, candidates, attempts=6, delay=1.0):
|
|
90
|
+
"""Try multiple selectors, with normal then JS click, retrying for dynamic UI."""
|
|
91
|
+
for attempt in range(1, attempts + 1):
|
|
92
|
+
for by, sel, label in candidates:
|
|
93
|
+
try:
|
|
94
|
+
elems = driver.find_elements(by, sel)
|
|
95
|
+
print(f"[click attempt {attempt}] selector={sel} label={label} found={len(elems)}")
|
|
96
|
+
for elem in elems:
|
|
97
|
+
if not elem.is_displayed() or not elem.is_enabled():
|
|
98
|
+
continue
|
|
99
|
+
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", elem)
|
|
100
|
+
try:
|
|
101
|
+
elem.click()
|
|
102
|
+
except Exception:
|
|
103
|
+
driver.execute_script("arguments[0].click();", elem)
|
|
104
|
+
print(f"[click attempt {attempt}] clicked {label} via {sel}")
|
|
105
|
+
return True
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"[click attempt {attempt}] selector={sel} error={e}")
|
|
108
|
+
continue
|
|
109
|
+
time.sleep(delay)
|
|
110
|
+
print("click_with_retries exhausted without a click")
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _log(logger, level, message, gray=False):
|
|
115
|
+
if logger:
|
|
116
|
+
try:
|
|
117
|
+
if gray:
|
|
118
|
+
logger.log(level, message, extra={"gray": True})
|
|
119
|
+
else:
|
|
120
|
+
logger.log(level, message)
|
|
121
|
+
return
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
print(message)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def first_time_setup(profile_name=None):
|
|
128
|
+
creds = load_credentials(profile_name)
|
|
129
|
+
profile_nickname = None
|
|
130
|
+
discord_webhook_url = None
|
|
131
|
+
enable_discord_webhook = None
|
|
132
|
+
if creds:
|
|
133
|
+
username = creds.get('username')
|
|
134
|
+
password = creds.get('password')
|
|
135
|
+
profile_nickname = creds.get('profile_nickname')
|
|
136
|
+
discord_webhook_url = creds.get('discord_webhook_url')
|
|
137
|
+
enable_discord_webhook = creds.get('enable_discord_webhook')
|
|
138
|
+
print('Loaded existing credentials. Starting automated login and 2FA binding...')
|
|
139
|
+
else:
|
|
140
|
+
username = input('Enter your Microsoft username: ')
|
|
141
|
+
password = input('Enter your Microsoft password: ')
|
|
142
|
+
if not profile_nickname:
|
|
143
|
+
while True:
|
|
144
|
+
profile_nickname = input('Enter your profile nickname: ').strip()
|
|
145
|
+
if profile_nickname:
|
|
146
|
+
break
|
|
147
|
+
print('Profile nickname cannot be empty. Please enter again.')
|
|
148
|
+
discord_webhook_url = input('Enter your Discord webhook URL (leave blank to disable): ').strip()
|
|
149
|
+
enable_discord_webhook = bool(discord_webhook_url)
|
|
150
|
+
|
|
151
|
+
payload = {
|
|
152
|
+
'username': username,
|
|
153
|
+
'password': password,
|
|
154
|
+
'profile_nickname': profile_nickname,
|
|
155
|
+
}
|
|
156
|
+
if discord_webhook_url is not None:
|
|
157
|
+
payload['discord_webhook_url'] = discord_webhook_url
|
|
158
|
+
payload['enable_discord_webhook'] = bool(discord_webhook_url) if enable_discord_webhook is None else bool(enable_discord_webhook)
|
|
159
|
+
|
|
160
|
+
credentials_path = get_credentials_path(profile_name)
|
|
161
|
+
with open(credentials_path, 'w') as f:
|
|
162
|
+
json.dump(payload, f)
|
|
163
|
+
print('Credentials saved. Starting automated login and 2FA binding...')
|
|
164
|
+
driver = start_driver()
|
|
165
|
+
# Automated login
|
|
166
|
+
driver.get(LOGIN_URL)
|
|
167
|
+
try:
|
|
168
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'loginfmt')))
|
|
169
|
+
driver.find_element(By.NAME, 'loginfmt').send_keys(username)
|
|
170
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
171
|
+
time.sleep(2)
|
|
172
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'passwd')))
|
|
173
|
+
driver.find_element(By.NAME, 'passwd').send_keys(password)
|
|
174
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
175
|
+
time.sleep(2)
|
|
176
|
+
# Continuously check and tick '不再显示此消息' checkbox, then click '是' button until navigation
|
|
177
|
+
for _ in range(30): # up to 30 seconds
|
|
178
|
+
try:
|
|
179
|
+
# Tick the checkbox if present and not already selected
|
|
180
|
+
try:
|
|
181
|
+
kmsi_checkbox = driver.find_element(By.ID, "KmsiCheckboxField")
|
|
182
|
+
if kmsi_checkbox.is_displayed() and kmsi_checkbox.is_enabled() and not kmsi_checkbox.is_selected():
|
|
183
|
+
print("Ticking '不再显示此消息' checkbox...")
|
|
184
|
+
kmsi_checkbox.click()
|
|
185
|
+
time.sleep(0.5)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
# Find the confirmation button robustly (submit, class contains 'button_primary', value is '是')
|
|
189
|
+
btn_candidates = driver.find_elements(By.CSS_SELECTOR, "input[type='submit'].button_primary")
|
|
190
|
+
btn_clicked = False
|
|
191
|
+
for btn in btn_candidates:
|
|
192
|
+
btn_class = btn.get_attribute('class') or ''
|
|
193
|
+
btn_value = btn.get_attribute('value') or ''
|
|
194
|
+
print(f"Found button: class={btn_class}, value={btn_value}")
|
|
195
|
+
match_values = ['是', '下一步', 'Yes', '同意', '确认', '继续', '登录', 'Sign in', 'Accept', 'Next', 'Continue']
|
|
196
|
+
if btn.is_displayed() and btn.is_enabled() and any(x in btn_value for x in match_values):
|
|
197
|
+
print(f"Attempting to click button with value '{btn_value}' ...")
|
|
198
|
+
try:
|
|
199
|
+
btn.click()
|
|
200
|
+
print(f"Clicked button '{btn_value}' with normal click.")
|
|
201
|
+
except Exception:
|
|
202
|
+
print("Normal click failed, trying JS click...")
|
|
203
|
+
driver.execute_script("arguments[0].click();", btn)
|
|
204
|
+
print(f"Clicked button '{btn_value}' with JS click.")
|
|
205
|
+
time.sleep(1)
|
|
206
|
+
btn_clicked = True
|
|
207
|
+
break
|
|
208
|
+
if not btn_clicked:
|
|
209
|
+
print("No clickable main button found, waiting...")
|
|
210
|
+
time.sleep(1)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
print(f"Exception in confirmation loop: {e}")
|
|
213
|
+
break # Button not present, stop loop
|
|
214
|
+
# Wait for navigation to security info page
|
|
215
|
+
print('Waiting for navigation to security info page...')
|
|
216
|
+
max_wait = 60
|
|
217
|
+
waited = 0
|
|
218
|
+
while waited < max_wait:
|
|
219
|
+
current_url = driver.current_url
|
|
220
|
+
# On first login, handle the "Don't show this again" prompt (KMSI) while user completes MFA on phone
|
|
221
|
+
try:
|
|
222
|
+
if driver.find_elements(By.ID, "KmsiCheckboxField"):
|
|
223
|
+
handle_kmsi(driver)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
if current_url.startswith(SECURITY_INFO_URL):
|
|
227
|
+
print('Successfully navigated to security info page. Automating authenticator binding...')
|
|
228
|
+
break
|
|
229
|
+
time.sleep(2)
|
|
230
|
+
waited += 2
|
|
231
|
+
else:
|
|
232
|
+
print('Did not reach security info page after login. Please check credentials or login flow.')
|
|
233
|
+
driver.quit()
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
# Step 1: Click <span> with data-automationid="splitbuttonprimary" and child <i> with data-icon-name="Add"
|
|
237
|
+
icon_add = WebDriverWait(driver, 20).until(
|
|
238
|
+
EC.presence_of_element_located((By.CSS_SELECTOR, 'span[data-automationid="splitbuttonprimary"] i[data-icon-name="Add"]'))
|
|
239
|
+
)
|
|
240
|
+
btn1 = icon_add.find_element(By.XPATH, './..')
|
|
241
|
+
btn1.click()
|
|
242
|
+
time.sleep(1)
|
|
243
|
+
# Step 2: Click button with data-testid="authmethod-picker-authenticatorApp"
|
|
244
|
+
btn2 = WebDriverWait(driver, 20).until(
|
|
245
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="authmethod-picker-authenticatorApp"]'))
|
|
246
|
+
)
|
|
247
|
+
btn2.click()
|
|
248
|
+
time.sleep(1)
|
|
249
|
+
# Step 3: Click button with class d_hxfHpJiF_9Hwnz7WNw
|
|
250
|
+
btn3 = WebDriverWait(driver, 20).until(
|
|
251
|
+
EC.element_to_be_clickable((By.CLASS_NAME, 'd_hxfHpJiF_9Hwnz7WNw'))
|
|
252
|
+
)
|
|
253
|
+
btn3.click()
|
|
254
|
+
time.sleep(1)
|
|
255
|
+
# Step 4: Click button with data-testid="reskin-step-next-button"
|
|
256
|
+
btn4 = WebDriverWait(driver, 20).until(
|
|
257
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="reskin-step-next-button"]'))
|
|
258
|
+
)
|
|
259
|
+
btn4.click()
|
|
260
|
+
time.sleep(1)
|
|
261
|
+
# Step 5: Click button with data-testid="activation-qr-show/hide-info-button"
|
|
262
|
+
btn5 = WebDriverWait(driver, 20).until(
|
|
263
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="activation-qr-show/hide-info-button"]'))
|
|
264
|
+
)
|
|
265
|
+
btn5.click()
|
|
266
|
+
time.sleep(1)
|
|
267
|
+
# Step 6: Extract secret from <tr> with data-testid="activation-url/key"
|
|
268
|
+
secret_elem = WebDriverWait(driver, 20).until(
|
|
269
|
+
EC.presence_of_element_located((By.CSS_SELECTOR, 'tr[data-testid="activation-url/key"]'))
|
|
270
|
+
)
|
|
271
|
+
secret = secret_elem.text.strip()
|
|
272
|
+
if secret:
|
|
273
|
+
bind(secret)
|
|
274
|
+
save_config(
|
|
275
|
+
username,
|
|
276
|
+
password,
|
|
277
|
+
secret,
|
|
278
|
+
profile_nickname,
|
|
279
|
+
discord_webhook_url=discord_webhook_url,
|
|
280
|
+
enable_discord_webhook=enable_discord_webhook,
|
|
281
|
+
profile_name=profile_name,
|
|
282
|
+
)
|
|
283
|
+
print(f'Authenticator bound and secret saved: {secret}')
|
|
284
|
+
# Click '下一步' button after copying secret
|
|
285
|
+
btn_next = WebDriverWait(driver, 20).until(
|
|
286
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="reskin-step-next-button"]'))
|
|
287
|
+
)
|
|
288
|
+
btn_next.click()
|
|
289
|
+
time.sleep(1)
|
|
290
|
+
# Step 7: Fill OTP in the input field (robust)
|
|
291
|
+
otp_input = WebDriverWait(driver, 20).until(
|
|
292
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, 'input[data-testid="verification-entercode-input"]'))
|
|
293
|
+
)
|
|
294
|
+
otp_input.clear()
|
|
295
|
+
otp = get_otp()
|
|
296
|
+
otp_input.send_keys(otp)
|
|
297
|
+
print(f'Filled OTP: {otp}')
|
|
298
|
+
# Click '下一步' button to submit OTP
|
|
299
|
+
btn_otp_next = WebDriverWait(driver, 20).until(
|
|
300
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="reskin-step-next-button"]'))
|
|
301
|
+
)
|
|
302
|
+
btn_otp_next.click()
|
|
303
|
+
time.sleep(1)
|
|
304
|
+
else:
|
|
305
|
+
print('Could not extract secret automatically. Please bind manually and update config.')
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f'Error during security info automation: {e}')
|
|
308
|
+
print('Automation complete. Waiting 10 seconds before exit...')
|
|
309
|
+
time.sleep(10)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
print(f'Error during automated login/setup: {e}')
|
|
312
|
+
driver.quit()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def start_driver():
|
|
316
|
+
options = Options()
|
|
317
|
+
options.add_argument('--no-sandbox')
|
|
318
|
+
options.add_argument('--disable-dev-shm-usage')
|
|
319
|
+
options.add_experimental_option('excludeSwitches', ['enable-logging'])
|
|
320
|
+
service = Service(ChromeDriverManager().install())
|
|
321
|
+
driver = webdriver.Chrome(service=service, options=options)
|
|
322
|
+
return driver
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def auto_login():
|
|
326
|
+
config = load_config()
|
|
327
|
+
creds = load_credentials()
|
|
328
|
+
if not config or not creds or 'secret' not in config or 'username' not in creds or 'password' not in creds:
|
|
329
|
+
print('Config or credentials missing/incomplete. Starting first-time setup...')
|
|
330
|
+
first_time_setup()
|
|
331
|
+
config = load_config()
|
|
332
|
+
creds = load_credentials()
|
|
333
|
+
if not config or not creds or 'secret' not in config or 'username' not in creds or 'password' not in creds:
|
|
334
|
+
raise RuntimeError('Failed to create valid config or credentials file. Please retry.')
|
|
335
|
+
username = creds['username']
|
|
336
|
+
password = creds['password']
|
|
337
|
+
secret = config.get('secret')
|
|
338
|
+
driver = start_driver()
|
|
339
|
+
driver.get(LOGIN_URL)
|
|
340
|
+
# --- Insert element automation here ---
|
|
341
|
+
# Example: fill username
|
|
342
|
+
try:
|
|
343
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'loginfmt')))
|
|
344
|
+
driver.find_element(By.NAME, 'loginfmt').send_keys(username)
|
|
345
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
346
|
+
time.sleep(2)
|
|
347
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'passwd')))
|
|
348
|
+
driver.find_element(By.NAME, 'passwd').send_keys(password)
|
|
349
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
350
|
+
time.sleep(2)
|
|
351
|
+
# If OTP requested, fill it
|
|
352
|
+
if 'Enter code' in driver.page_source or 'Verification code' in driver.page_source:
|
|
353
|
+
otp = get_otp()
|
|
354
|
+
# You will tell me the exact element for OTP input
|
|
355
|
+
print(f'Auto-filling OTP: {otp}')
|
|
356
|
+
# Example: driver.find_element(By.NAME, 'otc').send_keys(otp)
|
|
357
|
+
# driver.find_element(By.ID, 'idSubmit_SAOTCC_Continue').click()
|
|
358
|
+
print('Login automation complete. Please specify further element IDs/XPaths for full automation.')
|
|
359
|
+
except Exception as e:
|
|
360
|
+
print(f'Error during login automation: {e}')
|
|
361
|
+
# driver.quit() # Uncomment when ready
|
|
362
|
+
|
|
363
|
+
def login_with_credentials(driver, username, password):
|
|
364
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'loginfmt')))
|
|
365
|
+
driver.find_element(By.NAME, 'loginfmt').send_keys(username)
|
|
366
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
367
|
+
time.sleep(2)
|
|
368
|
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, 'passwd')))
|
|
369
|
+
driver.find_element(By.NAME, 'passwd').send_keys(password)
|
|
370
|
+
driver.find_element(By.ID, 'idSIButton9').click()
|
|
371
|
+
time.sleep(2)
|
|
372
|
+
# Continuously check and tick '不再显示此消息' checkbox, then click '是' button until navigation
|
|
373
|
+
for _ in range(30): # up to 30 seconds
|
|
374
|
+
try:
|
|
375
|
+
# Tick the checkbox if present and not already selected
|
|
376
|
+
try:
|
|
377
|
+
kmsi_checkbox = driver.find_element(By.ID, "KmsiCheckboxField")
|
|
378
|
+
if kmsi_checkbox.is_displayed() and kmsi_checkbox.is_enabled() and not kmsi_checkbox.is_selected():
|
|
379
|
+
print("Ticking '不再显示此消息' checkbox...")
|
|
380
|
+
kmsi_checkbox.click()
|
|
381
|
+
time.sleep(0.5)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
# Find the confirmation button robustly (submit, class contains 'button_primary', value is '是')
|
|
385
|
+
btn_candidates = driver.find_elements(By.CSS_SELECTOR, "input[type='submit'].button_primary")
|
|
386
|
+
btn_clicked = False
|
|
387
|
+
for btn in btn_candidates:
|
|
388
|
+
btn_class = btn.get_attribute('class') or ''
|
|
389
|
+
btn_value = btn.get_attribute('value') or ''
|
|
390
|
+
print(f"Found button: class={btn_class}, value={btn_value}")
|
|
391
|
+
match_values = ['是', '下一步', 'Yes', '同意', '确认', '继续', '登录', 'Sign in', 'Accept', 'Next', 'Continue']
|
|
392
|
+
if btn.is_displayed() and btn.is_enabled() and any(x in btn_value for x in match_values):
|
|
393
|
+
print(f"Attempting to click button with value '{btn_value}' ...")
|
|
394
|
+
try:
|
|
395
|
+
btn.click()
|
|
396
|
+
print(f"Clicked button '{btn_value}' with normal click.")
|
|
397
|
+
except Exception:
|
|
398
|
+
print("Normal click failed, trying JS click...")
|
|
399
|
+
driver.execute_script("arguments[0].click();", btn)
|
|
400
|
+
print(f"Clicked button '{btn_value}' with JS click.")
|
|
401
|
+
time.sleep(1)
|
|
402
|
+
btn_clicked = True
|
|
403
|
+
break
|
|
404
|
+
if not btn_clicked:
|
|
405
|
+
print("No clickable main button found, waiting...")
|
|
406
|
+
time.sleep(1)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
print(f"Exception in confirmation loop: {e}")
|
|
409
|
+
break # Button not present, stop loop
|
|
410
|
+
|
|
411
|
+
def fill_otp(driver):
|
|
412
|
+
try:
|
|
413
|
+
otp_input = WebDriverWait(driver, 10).until(
|
|
414
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, 'input[data-testid="verification-entercode-input"]'))
|
|
415
|
+
)
|
|
416
|
+
otp_input.clear()
|
|
417
|
+
otp = get_otp()
|
|
418
|
+
otp_input.send_keys(otp)
|
|
419
|
+
print(f'Filled OTP: {otp}')
|
|
420
|
+
btn_otp_next = WebDriverWait(driver, 10).until(
|
|
421
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, '[data-testid="reskin-step-next-button"]'))
|
|
422
|
+
)
|
|
423
|
+
btn_otp_next.click()
|
|
424
|
+
time.sleep(1)
|
|
425
|
+
return True
|
|
426
|
+
except Exception as e:
|
|
427
|
+
print(f'OTP input not found or not needed: {e}')
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def fill_ms_login(driver, username, password):
|
|
432
|
+
"""Robust fill for Microsoft login page (prefers password-first)."""
|
|
433
|
+
try:
|
|
434
|
+
click_by_xpath_contains_text(driver, 'Accept', timeout=2) # cookie banner if any
|
|
435
|
+
maybe_switch_to_login_iframe(driver)
|
|
436
|
+
|
|
437
|
+
# Account picker
|
|
438
|
+
candidates = [username, username.split('@')[0], 'Use another account', 'Other account']
|
|
439
|
+
for text in candidates:
|
|
440
|
+
if click_by_xpath_contains_text(driver, text, timeout=3):
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
def visible_el(by, sel, wait=8):
|
|
444
|
+
try:
|
|
445
|
+
el = WebDriverWait(driver, wait).until(EC.presence_of_element_located((by, sel)))
|
|
446
|
+
if el.is_displayed():
|
|
447
|
+
return el
|
|
448
|
+
except Exception:
|
|
449
|
+
return None
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
pwd_input = visible_el(By.ID, 'i0118') or visible_el(By.NAME, 'passwd')
|
|
453
|
+
|
|
454
|
+
user_input = None
|
|
455
|
+
for by, sel in [(By.NAME, 'loginfmt'), (By.ID, 'i0116')]:
|
|
456
|
+
el = visible_el(by, sel)
|
|
457
|
+
if el:
|
|
458
|
+
user_input = el
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
if user_input:
|
|
462
|
+
user_input.clear()
|
|
463
|
+
driver.execute_script("arguments[0].focus();", user_input)
|
|
464
|
+
try:
|
|
465
|
+
user_input.send_keys(username)
|
|
466
|
+
except Exception:
|
|
467
|
+
driver.execute_script("arguments[0].value = arguments[1];", user_input, username)
|
|
468
|
+
try:
|
|
469
|
+
btn = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, 'idSIButton9')))
|
|
470
|
+
driver.execute_script("arguments[0].click();", btn)
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
time.sleep(1.0)
|
|
474
|
+
pwd_input = visible_el(By.ID, 'i0118') or visible_el(By.NAME, 'passwd')
|
|
475
|
+
|
|
476
|
+
if pwd_input:
|
|
477
|
+
pwd_input.clear()
|
|
478
|
+
driver.execute_script("arguments[0].focus();", pwd_input)
|
|
479
|
+
try:
|
|
480
|
+
pwd_input.send_keys(password)
|
|
481
|
+
except Exception:
|
|
482
|
+
driver.execute_script("arguments[0].value = arguments[1];", pwd_input, password)
|
|
483
|
+
try:
|
|
484
|
+
btn = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, 'idSIButton9')))
|
|
485
|
+
driver.execute_script("arguments[0].click();", btn)
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
time.sleep(1.0)
|
|
489
|
+
return True
|
|
490
|
+
|
|
491
|
+
print("Password input not found or not interactable.")
|
|
492
|
+
return False
|
|
493
|
+
except Exception as e:
|
|
494
|
+
print(f"MS login fill failed: {e}")
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def handle_mfa_code(driver):
|
|
499
|
+
"""Fallback path: use verification code instead of Authenticator app."""
|
|
500
|
+
try:
|
|
501
|
+
maybe_switch_to_login_iframe(driver)
|
|
502
|
+
# Step 1: open alternative methods
|
|
503
|
+
try:
|
|
504
|
+
alt_link = WebDriverWait(driver, 6).until(
|
|
505
|
+
EC.element_to_be_clickable((By.ID, 'signInAnotherWay'))
|
|
506
|
+
)
|
|
507
|
+
driver.execute_script("arguments[0].click();", alt_link)
|
|
508
|
+
print("Clicked 'signInAnotherWay' link")
|
|
509
|
+
except Exception:
|
|
510
|
+
click_by_xpath_contains_text(driver, "I can't use my Microsoft Authenticator app right now", timeout=5)
|
|
511
|
+
|
|
512
|
+
# Step 2: choose verification code and wait for OTP input
|
|
513
|
+
candidates = [
|
|
514
|
+
(By.CSS_SELECTOR, "div.table[role='button'][data-value='PhoneAppOTP']", "PhoneAppOTP table role button"),
|
|
515
|
+
(By.CSS_SELECTOR, "#idDiv_SAOTCS_Proofs div.table[role='button'][data-value='PhoneAppOTP']", "PhoneAppOTP table inside proofs"),
|
|
516
|
+
(By.CSS_SELECTOR, "div[role='button'][data-value='PhoneAppOTP']", "PhoneAppOTP role button"),
|
|
517
|
+
(By.CSS_SELECTOR, "[data-value='PhoneAppOTP']", "PhoneAppOTP data-value"),
|
|
518
|
+
(By.CSS_SELECTOR, "div.row.tile [data-value='PhoneAppOTP']", "row tile data-value PhoneAppOTP"),
|
|
519
|
+
(By.CSS_SELECTOR, "div.row.tile", "row tile"),
|
|
520
|
+
(By.CSS_SELECTOR, "div.row.tile[role='listitem']", "row tile listitem"),
|
|
521
|
+
(By.CSS_SELECTOR, "#idDiv_SAOTCS_Proofs > div:nth-child(2) > div > div > div.table-cell.text-left.content > div", "provided CSS"),
|
|
522
|
+
(By.XPATH, "//*[@id='idDiv_SAOTCS_Proofs']/div[2]/div/div/div[2]/div", "provided XPath"),
|
|
523
|
+
(By.XPATH, "/html/body/div/form[1]/div/div/div[2]/div[1]/div/div/div/div/div/div[2]/div[2]/div/div[2]/div/div[2]/div[2]/div[2]/div/div/div[2]/div", "provided absolute XPath"),
|
|
524
|
+
(By.CSS_SELECTOR, "#idDiv_SAOTCS_Proofs .table-row", "tile row"),
|
|
525
|
+
(By.CSS_SELECTOR, "#idDiv_SAOTCS_Proofs .table-row .table-cell.text-left.content", "tile content cell"),
|
|
526
|
+
(By.XPATH, "//*[@id='idDiv_SAOTCS_Proofs']//div[contains(@class,'table-row')]//div[contains(@class,'text-left')]/div[contains(normalize-space(.), 'Use a verification code')]/ancestor::div[contains(@class,'table-row')][1]", "table-row ancestor of text"),
|
|
527
|
+
(By.XPATH, "//img[contains(@src,'picker_verify_code')]/ancestor::div[contains(@class,'table-row')][1]", "verify-code image row"),
|
|
528
|
+
(By.CSS_SELECTOR, "img[src*='picker_verify_code']", "verify-code img"),
|
|
529
|
+
(By.CSS_SELECTOR, "#idDiv_SAOTCS_Proofs", "proofs container"),
|
|
530
|
+
(By.XPATH, "//*[@id='idDiv_SAOTCS_Proofs']//div[contains(normalize-space(.), 'Use a verification code')]/parent::*", "text parent"),
|
|
531
|
+
(By.XPATH, "//*[@id='idDiv_SAOTCS_Proofs']//div[contains(normalize-space(.), 'Use a verification code')]/parent::div/parent::div", "text grandparent"),
|
|
532
|
+
(By.XPATH, "//*[@id='idDiv_SAOTCS_Proofs']//div[contains(@class,'table-cell')][.//div[contains(normalize-space(.), 'Use a verification code')]]", "cell containing text"),
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
otp_input = None
|
|
536
|
+
for _ in range(2):
|
|
537
|
+
chosen = False
|
|
538
|
+
if click_with_retries(driver, candidates, attempts=6, delay=0.8):
|
|
539
|
+
chosen = True
|
|
540
|
+
elif click_by_xpath_contains_text(driver, "Use a verification code", timeout=5):
|
|
541
|
+
chosen = True
|
|
542
|
+
elif click_by_xpath_contains_text(driver, "Use verification code", timeout=5):
|
|
543
|
+
chosen = True
|
|
544
|
+
else:
|
|
545
|
+
try:
|
|
546
|
+
elem = WebDriverWait(driver, 6).until(
|
|
547
|
+
EC.presence_of_element_located((By.XPATH, "//div[contains(normalize-space(.), 'Use a verification code')]/ancestor::*[self::button or self::a or @role='button' or @tabindex='0'][1]"))
|
|
548
|
+
)
|
|
549
|
+
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", elem)
|
|
550
|
+
driver.execute_script("arguments[0].click();", elem)
|
|
551
|
+
print("Clicked ancestor container for 'Use a verification code'")
|
|
552
|
+
chosen = True
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
if not chosen:
|
|
557
|
+
print("Could not select 'Use a verification code'.")
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
print("Waiting for OTP input after clicking verification code option...")
|
|
561
|
+
selectors = [
|
|
562
|
+
(By.NAME, 'otc'),
|
|
563
|
+
(By.ID, 'idTxtBx_SAOTCC_OTC'),
|
|
564
|
+
(By.CSS_SELECTOR, 'input[data-testid="verification-entercode-input"]'),
|
|
565
|
+
(By.CSS_SELECTOR, 'input[name="otc"]'),
|
|
566
|
+
]
|
|
567
|
+
for by, sel in selectors:
|
|
568
|
+
try:
|
|
569
|
+
otp_input = WebDriverWait(driver, 12).until(EC.element_to_be_clickable((by, sel)))
|
|
570
|
+
break
|
|
571
|
+
except Exception:
|
|
572
|
+
continue
|
|
573
|
+
if otp_input:
|
|
574
|
+
break
|
|
575
|
+
else:
|
|
576
|
+
print("OTP input still not visible; retrying click on verification code option...")
|
|
577
|
+
|
|
578
|
+
if not otp_input:
|
|
579
|
+
print("OTP input not found after clicking verification code option.")
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
otp_input.clear()
|
|
583
|
+
otp = get_otp()
|
|
584
|
+
otp_input.send_keys(otp)
|
|
585
|
+
print(f"Filled OTP: {otp}")
|
|
586
|
+
|
|
587
|
+
verify_selectors = [
|
|
588
|
+
(By.ID, 'idSubmit_SAOTCC_Continue'),
|
|
589
|
+
(By.CSS_SELECTOR, '[data-testid="reskin-step-next-button"]'),
|
|
590
|
+
]
|
|
591
|
+
clicked = False
|
|
592
|
+
for by, sel in verify_selectors:
|
|
593
|
+
try:
|
|
594
|
+
btn = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((by, sel)))
|
|
595
|
+
driver.execute_script("arguments[0].click();", btn)
|
|
596
|
+
clicked = True
|
|
597
|
+
break
|
|
598
|
+
except Exception:
|
|
599
|
+
continue
|
|
600
|
+
if not clicked:
|
|
601
|
+
if click_by_xpath_contains_text(driver, 'Verify', timeout=5):
|
|
602
|
+
clicked = True
|
|
603
|
+
if not clicked:
|
|
604
|
+
print("Verify button not found.")
|
|
605
|
+
return False
|
|
606
|
+
time.sleep(2)
|
|
607
|
+
return True
|
|
608
|
+
except Exception as e:
|
|
609
|
+
print(f"MFA fallback failed: {e}")
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def handle_kmsi(driver):
|
|
614
|
+
"""Tick 'Don't show this again' (KMSI) and confirm Yes/Next."""
|
|
615
|
+
try:
|
|
616
|
+
kmsi_checkbox = driver.find_element(By.ID, "KmsiCheckboxField")
|
|
617
|
+
if kmsi_checkbox.is_displayed() and kmsi_checkbox.is_enabled() and not kmsi_checkbox.is_selected():
|
|
618
|
+
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", kmsi_checkbox)
|
|
619
|
+
try:
|
|
620
|
+
kmsi_checkbox.click()
|
|
621
|
+
except Exception:
|
|
622
|
+
driver.execute_script("arguments[0].click();", kmsi_checkbox)
|
|
623
|
+
print("Ticked KMSI checkbox")
|
|
624
|
+
time.sleep(0.3)
|
|
625
|
+
except Exception:
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
candidates = [
|
|
629
|
+
(By.ID, 'idSIButton9', 'idSIButton9'),
|
|
630
|
+
(By.CSS_SELECTOR, "input[type='submit'].button_primary", "button_primary submit"),
|
|
631
|
+
(By.XPATH, "//input[@type='submit' and (contains(@value,'Yes') or contains(@value,'是') or contains(@value,'继续') or contains(@value,'Next') or contains(@value,'Sign in'))]", "submit value match"),
|
|
632
|
+
(By.XPATH, "//button[contains(normalize-space(.), 'Yes') or contains(normalize-space(.), '是') or contains(normalize-space(.), '继续') or contains(normalize-space(.), 'Next') or contains(normalize-space(.), 'Sign in') or contains(normalize-space(.), 'Accept') or contains(normalize-space(.), '登录') or contains(normalize-space(.), '同意')]", "button text match"),
|
|
633
|
+
]
|
|
634
|
+
if click_with_retries(driver, candidates, attempts=5, delay=0.6):
|
|
635
|
+
print("Clicked KMSI confirmation button")
|
|
636
|
+
return True
|
|
637
|
+
if click_by_xpath_contains_text(driver, 'Yes', timeout=3) or click_by_xpath_contains_text(driver, 'Next', timeout=3):
|
|
638
|
+
print("Clicked KMSI confirmation via text fallback")
|
|
639
|
+
return True
|
|
640
|
+
print("KMSI confirmation button not found")
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def renew_login(driver, expected_url=None, logger=None, profile_name=None):
|
|
645
|
+
"""Non-first login: fill credentials, handle MFA, KMSI."""
|
|
646
|
+
creds = load_credentials(profile_name)
|
|
647
|
+
if not creds or 'username' not in creds or 'password' not in creds:
|
|
648
|
+
_log(logger, logging.ERROR if logger else logging.INFO, 'credentials.json missing username/password; cannot auto-login.')
|
|
649
|
+
return False
|
|
650
|
+
username = creds['username']
|
|
651
|
+
password = creds['password']
|
|
652
|
+
|
|
653
|
+
try:
|
|
654
|
+
current_url = driver.current_url
|
|
655
|
+
page_src = driver.page_source
|
|
656
|
+
if ('login.microsoftonline.com' in current_url) or ('mysignins.microsoft.com' in current_url) or ('loginfmt' in page_src):
|
|
657
|
+
_log(logger, logging.INFO, "Detected Microsoft login page; attempting auto login...")
|
|
658
|
+
fill_ms_login(driver, username, password)
|
|
659
|
+
handle_mfa_code(driver)
|
|
660
|
+
handle_kmsi(driver)
|
|
661
|
+
else:
|
|
662
|
+
_log(logger, logging.INFO, "No Microsoft login page detected; skipping renew_login.")
|
|
663
|
+
return True
|
|
664
|
+
|
|
665
|
+
if expected_url:
|
|
666
|
+
try:
|
|
667
|
+
WebDriverWait(driver, 60).until(EC.url_contains(expected_url))
|
|
668
|
+
_log(logger, logging.INFO, "Login detected via URL match.")
|
|
669
|
+
return True
|
|
670
|
+
except Exception:
|
|
671
|
+
_log(logger, logging.ERROR if logger else logging.INFO, "Login not detected within timeout.")
|
|
672
|
+
return False
|
|
673
|
+
return True
|
|
674
|
+
except Exception as e:
|
|
675
|
+
_log(logger, logging.ERROR if logger else logging.INFO, f"renew_login failed: {e}")
|
|
676
|
+
return False
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def verify_login(driver, expected_url, max_wait_minutes=30, logger=None):
|
|
680
|
+
initial_wait = 3
|
|
681
|
+
periodic_wait = 10
|
|
682
|
+
max_wait_seconds = max_wait_minutes * 60
|
|
683
|
+
elapsed_time = 0
|
|
684
|
+
|
|
685
|
+
time.sleep(initial_wait)
|
|
686
|
+
current_url = driver.current_url
|
|
687
|
+
|
|
688
|
+
if current_url == expected_url:
|
|
689
|
+
_log(logger, logging.INFO, "Already logged in.")
|
|
690
|
+
return True
|
|
691
|
+
else:
|
|
692
|
+
_log(logger, logging.INFO, "Need to login.")
|
|
693
|
+
|
|
694
|
+
while current_url != expected_url and elapsed_time < max_wait_seconds:
|
|
695
|
+
time.sleep(periodic_wait)
|
|
696
|
+
elapsed_time += periodic_wait
|
|
697
|
+
current_url = driver.current_url
|
|
698
|
+
if current_url == expected_url:
|
|
699
|
+
_log(logger, logging.INFO, "Login detected.")
|
|
700
|
+
return True
|
|
701
|
+
else:
|
|
702
|
+
_log(logger, logging.INFO, "Waiting for login...")
|
|
703
|
+
|
|
704
|
+
_log(logger, logging.ERROR if logger else logging.INFO, f"Login not detected after {max_wait_minutes} minutes.")
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def attempt_login(driver, expected_url, broadcaster=None, logger=None, profile_name=None):
|
|
709
|
+
"""Try automatic MS login using stored credentials and OTP."""
|
|
710
|
+
try:
|
|
711
|
+
try:
|
|
712
|
+
WebDriverWait(driver, 6).until(EC.presence_of_element_located((By.ID, "pbid-blockFoundHappeningNowAttending")))
|
|
713
|
+
_log(logger, logging.INFO, "Existing session is valid; skipping renew_login.", gray=True)
|
|
714
|
+
return True
|
|
715
|
+
except Exception:
|
|
716
|
+
_log(logger, logging.INFO, "Session check did not confirm login; attempting renew_login.")
|
|
717
|
+
|
|
718
|
+
result = renew_login(driver, expected_url, logger=logger, profile_name=profile_name)
|
|
719
|
+
if result:
|
|
720
|
+
verified = verify_login(driver, expected_url, max_wait_minutes=5, logger=logger)
|
|
721
|
+
if verified and broadcaster:
|
|
722
|
+
try:
|
|
723
|
+
broadcaster.notify_renew_login_success()
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
return verified
|
|
727
|
+
return False
|
|
728
|
+
except Exception as e:
|
|
729
|
+
_log(logger, logging.ERROR if logger else logging.INFO, f"Auto-login attempt failed: {e}")
|
|
730
|
+
return False
|
|
731
|
+
|
|
732
|
+
if __name__ == '__main__':
|
|
733
|
+
import argparse
|
|
734
|
+
|
|
735
|
+
parser = argparse.ArgumentParser(description="RHUL auto login setup")
|
|
736
|
+
parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
|
|
737
|
+
args = parser.parse_args()
|
|
738
|
+
profile_name = args.profile if args.profile else prompt_select_profile()
|
|
739
|
+
first_time_setup(profile_name=profile_name)
|