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.
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)