// ==UserScript== // @name WHUT选课跳过弹窗 // @namespace http://tampermonkey.net/ // @version 2.0.0 // @description 区分未登录验证码状态,可靠捕获登录响应,不阻塞选课确认 API。 // @author 毫厘 // @match https://jwxk.whut.edu.cn/* // @run-at document-start // @grant unsafeWindow // @grant GM_cookie // @license MIT // ==/UserScript== (function() { 'use strict'; const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const nativeFetch = (pageWindow.fetch || window.fetch).bind(pageWindow); const ORIGIN = 'https://jwxk.whut.edu.cn'; const LOGIN_PATH = '/xsxk/auth/login'; const ELECTIVE_USER_URL = `${ORIGIN}/xsxk/elective/user`; const GRABLESSONS_URL = `${ORIGIN}/xsxk/elective/grablessons`; const AUTH_COOKIE_PATHS = [ '/', '/xsxk', '/xsxk/', '/xsxk/profile', '/xsxk/profile/', '/xsxk/profile/index.html', '/xsxk/elective', '/xsxk/elective/', '/xsxk/elective/grablessons' ]; const REQUEST_TIMEOUT = 10000; const GM_OPERATION_TIMEOUT = 1800; const PROFILE_AUTO_ENTRY_INITIAL_DELAY = 1500; const BATCH_ID_PATTERN = /^[A-Za-z0-9_-]{8,100}$/; let handlingLogin = false; let handledToken = ''; let currentFlow = 'boot'; let loginRequestObserved = false; let authCaptchaObserved = false; let unauthenticatedDetected = false; let loginWaitToastShown = false; let loginRequiredToastNode = null; let cachedSessionFlowStarted = false; let profileAutoEntryTimer = null; let latestObservedToken = ''; const debugBuffer = []; try { const previousLogs = JSON.parse(sessionStorage.getItem('WHUT_SKIP_ENTRY_DEBUG') || '[]'); if (Array.isArray(previousLogs)) { debugBuffer.push(...previousLogs.slice(-160)); } } catch (error) {} console.warn('%c WHUT-SkipEntry v2.0.0 API-only 已启动:只等待登录成功,不修改网页返回数据', 'background:#2655c8;color:#fff;font-weight:bold;'); console.info('[WHUT-SkipEntry] 失败后可执行:WHUT_SKIP_ENTRY_STATUS() 查看状态;WHUT_SKIP_ENTRY_DUMP() 导出本次流程日志。'); function summarize(value, depth = 0) { if (depth > 5) return '[深度已截断]'; if (Array.isArray(value)) return value.slice(0, 30).map(item => summarize(item, depth + 1)); if (!value || typeof value !== 'object') { if (typeof value === 'string' && value.length > 500) return `${value.slice(0, 500)}…`; return value; } const result = {}; Object.entries(value).forEach(([key, child]) => { const normalizedKey = key.toLowerCase(); if ( normalizedKey.includes('password') || normalizedKey === 'authorization' || normalizedKey === 'token' || normalizedKey === 'secretval' ) { result[key] = '[已隐藏]'; return; } result[key] = summarize(child, depth + 1); }); return result; } function redactText(value) { return String(value ?? '') .replace(/("(?:token|password|secretVal|authorization)"\s*:\s*")[^"]*(")/gi, '$1[已隐藏]$2') .replace(/((?:token|password|secretVal|authorization)\s*[=:]\s*)[^&\s,;}]+/gi, '$1[已隐藏]'); } function log(level, event, detail = {}) { const method = console[level] ? level : 'log'; const entry = { time: new Date().toLocaleTimeString('zh-CN', { hour12: false }), level, event, detail: summarize(detail), flow: currentFlow }; debugBuffer.push(entry); if (debugBuffer.length > 200) debugBuffer.shift(); try { sessionStorage.setItem('WHUT_SKIP_ENTRY_DEBUG', JSON.stringify(debugBuffer.slice(-80))); } catch (error) {} const args = [ `%cWHUT-SkipEntry%c ${event}`, 'background:#2655c8;color:#fff;font-weight:bold;padding:2px 5px;border-radius:4px;', 'color:#2655c8;font-weight:bold;', entry.detail ]; console[method](...args); try { if (pageWindow.console && pageWindow.console !== console && pageWindow.console[method]) { pageWindow.console[method](...args); } pageWindow.WHUT_SKIP_ENTRY_DEBUG = debugBuffer.slice(); exposeDiagnostics(); } catch (error) {} } function setFlow(flow) { currentFlow = flow; log('info', 'flow-state', { state: flow }); } function exposeDiagnostics() { try { pageWindow.WHUT_SKIP_ENTRY_DUMP = () => debugBuffer.slice(); pageWindow.WHUT_SKIP_ENTRY_STATUS = () => ({ version: '2.0.0', flow: currentFlow, handlingLogin, loginRequestObserved, authCaptchaObserved, unauthenticatedDetected, loginWaitToastShown, cachedSessionFlowStarted, handledTokenTime: readTokenTime(handledToken), latestObservedTokenTime: readTokenTime(latestObservedToken), href: pageWindow.location?.href || location.href, loginPath: LOGIN_PATH, cookie: getDocumentAuthorizationCookieInfo(), loginSnapshot: (() => { const snapshot = loadLoginSnapshot(); return snapshot ? { savedAt: snapshot.savedAt, tokenTime: snapshot.tokenTime, batchIds: snapshot.batches.map(batch => batch.batchId) } : null; })(), debugCount: debugBuffer.length }); pageWindow.WHUT_SKIP_ENTRY_RETRY = () => { handlingLogin = false; handledToken = ''; setFlow('manual-retry'); toast('已重置跳过流程,请重新点击登录', 'info'); log('warn', 'manual-retry-requested'); }; } catch (error) {} } function toast(message, type = 'info') { log(type === 'error' ? 'error' : type === 'warn' ? 'warn' : 'info', `toast:${type}`, { message }); const mount = document.body || document.documentElement; if (!mount) { document.addEventListener('DOMContentLoaded', () => toast(message, type), { once: true }); return; } const colors = { info: '#2655c8', success: '#059669', warn: '#d97706', error: '#dc2626' }; const icons = { info: 'ℹ️', success: '✅', warn: '⚠️', error: '❌' }; const node = document.createElement('div'); node.textContent = `${icons[type] || icons.info} ${message}`; node.style.cssText = [ 'position:fixed', 'top:18px', 'left:50%', 'transform:translateX(-50%)', 'z-index:2147483647', `background:${colors[type] || colors.info}`, 'color:#fff', 'border-radius:10px', 'box-shadow:0 8px 28px rgba(15,23,42,.28)', 'padding:10px 16px', 'max-width:min(760px,92vw)', 'font-size:14px', 'font-weight:700', 'line-height:1.5', 'white-space:pre-wrap', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' ].join(';'); mount.appendChild(node); if (message === '当前尚未登录,请完成验证码后再登录') { loginRequiredToastNode = node; } setTimeout(() => { node.remove(); if (loginRequiredToastNode === node) loginRequiredToastNode = null; }, type === 'error' ? 9000 : type === 'warn' ? 6500 : 3600); } function isLoginUrl(url) { try { const parsed = new URL(String(url || ''), location.href); return parsed.origin === ORIGIN && parsed.pathname.replace(/\/+$/, '') === LOGIN_PATH; } catch (error) { return false; } } function getApiPath(url) { try { const parsed = new URL(String(url || ''), location.href); return parsed.origin === ORIGIN && parsed.pathname.startsWith('/xsxk/') ? parsed.pathname : ''; } catch (error) { return ''; } } function isAuthCaptchaPath(apiPath) { return /\/auth\/captcha\/?$/i.test(String(apiPath || '')); } function hasLoginFormVisible() { try { const passwordInput = document.querySelector( 'input[type="password"], input[name*="password" i], input[id*="password" i]' ); const captchaInput = document.querySelector( 'input[name*="captcha" i], input[id*="captcha" i], input[placeholder*="验证码"]' ); return Boolean(passwordInput && captchaInput); } catch (error) { return false; } } function showLoginRequiredToast() { if (loginWaitToastShown || !isProfileIndexPage()) return; loginWaitToastShown = true; toast('当前尚未登录,请完成验证码后再登录', 'warn'); } function dismissLoginRequiredToast() { if (loginRequiredToastNode) { loginRequiredToastNode.remove(); loginRequiredToastNode = null; } } function markCaptchaObserved(source, detail = {}) { authCaptchaObserved = true; unauthenticatedDetected = true; cachedSessionFlowStarted = false; setFlow('awaiting-login'); log('info', 'unauthenticated-captcha-observed', { source, message: '检测到登录验证码请求,停止使用旧轮次快照,等待用户完成登录。', ...detail }); showLoginRequiredToast(); } function createAuthenticationRequiredError(message) { const error = new Error(message || '当前会话未登录'); error.code = 'WHUT_AUTH_REQUIRED'; return error; } function getXhrResponseText(xhr) { try { return typeof xhr?.responseText === 'string' ? xhr.responseText : ''; } catch (error) { return ''; } } function withTimeout(promise, ms, message) { let timer = null; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } function validToken(value) { return typeof value === 'string' && /^[A-Za-z0-9_.-]+$/.test(value); } function readTokenTime(value) { try { const payload = String(value || '').split('.')[1] || ''; const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); const json = JSON.parse(atob(padded)); return Number(json?.time || json?.iat || 0) || 0; } catch (error) { return 0; } } function gmCookieAvailable() { return typeof GM_cookie !== 'undefined' && GM_cookie && typeof GM_cookie.list === 'function' && typeof GM_cookie.delete === 'function' && typeof GM_cookie.set === 'function'; } function gmCookieList(details = {}) { return new Promise(resolve => { if (!gmCookieAvailable()) return resolve([]); let settled = false; const finish = value => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }; const timer = setTimeout(() => finish([]), GM_OPERATION_TIMEOUT); try { GM_cookie.list(details, (cookies, error) => { finish(error || !Array.isArray(cookies) ? [] : cookies); }); } catch (error) { finish([]); } }); } function gmCookieDelete(details) { return new Promise(resolve => { if (!gmCookieAvailable()) return resolve(false); let settled = false; const finish = value => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }; const timer = setTimeout(() => finish(false), GM_OPERATION_TIMEOUT); try { GM_cookie.delete(details, () => finish(true)); } catch (error) { finish(false); } }); } function gmCookieSet(details) { return new Promise(resolve => { if (!gmCookieAvailable()) return resolve(false); let settled = false; const finish = value => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }; const timer = setTimeout(() => finish(false), GM_OPERATION_TIMEOUT); try { GM_cookie.set(details, () => finish(true)); } catch (error) { finish(false); } }); } function expireDocumentAuthorizationCookies() { const host = location.hostname; ['', host, `.${host}`].forEach(domain => { const domainText = domain ? `; domain=${domain}` : ''; AUTH_COOKIE_PATHS.forEach(path => { document.cookie = `Authorization=; path=${path}${domainText}; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; secure; SameSite=Lax`; document.cookie = `Authorization=; path=${path}${domainText}; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; SameSite=Lax`; }); }); } async function clearAuthorizationCookies(reason = 'manual') { const before = getDocumentAuthorizationCookieInfo(); expireDocumentAuthorizationCookies(); let deletedCount = 0; let listedCount = 0; if (gmCookieAvailable()) { const cookies = await gmCookieList({ name: 'Authorization' }); listedCount = cookies.length; const targets = cookies.filter(cookie => { const domain = String(cookie.domain || '').replace(/^\./, ''); return !domain || domain === location.hostname || domain.endsWith(`.${location.hostname}`); }); await Promise.all(targets.map(async cookie => { const details = { name: 'Authorization', domain: cookie.domain, path: cookie.path || '/' }; const ok = await gmCookieDelete(details); if (ok) deletedCount++; })); } expireDocumentAuthorizationCookies(); log('info', 'authorization-cookie-cleared', { reason, before, listedCount, deletedCount, gmCookieAvailable: gmCookieAvailable(), after: getDocumentAuthorizationCookieInfo() }); } function getDocumentAuthorizationCookieInfo() { const values = getDocumentAuthorizationValues(); return { count: values.length, uniqueCount: new Set(values).size, tokenTimes: [...new Set(values)].map(readTokenTime).filter(Boolean) }; } function getDocumentAuthorizationValues() { return document.cookie .split(';') .map(item => item.trim()) .filter(item => item.startsWith('Authorization=')) .map(item => { const raw = item.slice('Authorization='.length); try { return decodeURIComponent(raw); } catch (error) { return raw; } }) .filter(Boolean); } async function readLatestAuthorizationToken() { const values = [...getDocumentAuthorizationValues()]; if (validToken(latestObservedToken)) values.push(latestObservedToken); if (gmCookieAvailable()) { const cookies = await gmCookieList({ name: 'Authorization' }); cookies.forEach(cookie => { if (cookie?.value && validToken(String(cookie.value))) values.push(String(cookie.value)); }); } const uniqueValues = [...new Set(values.filter(validToken))]; uniqueValues.sort((left, right) => readTokenTime(right) - readTokenTime(left)); const token = uniqueValues[0] || ''; log('info', 'latest-authorization-token-read', { sourceCount: values.length, uniqueCount: uniqueValues.length, selectedTokenTime: readTokenTime(token), cookie: getDocumentAuthorizationCookieInfo() }); return token; } function setDocumentAuthorizationCookie(token) { const encoded = encodeURIComponent(token); const host = location.hostname; ['', host, `.${host}`].forEach(domain => { const domainText = domain ? `; domain=${domain}` : ''; AUTH_COOKIE_PATHS.forEach(path => { document.cookie = `Authorization=${encoded}; path=${path}${domainText}; max-age=7200; secure; SameSite=Lax`; }); }); } function primeAuthorizationCookie(token) { if (!validToken(token)) return; expireDocumentAuthorizationCookies(); setDocumentAuthorizationCookie(token); log('info', 'authorization-cookie-primed', { tokenTime: readTokenTime(token), cookie: getDocumentAuthorizationCookieInfo() }); } function startBackgroundAuthorizationCookieSync(token) { if (!validToken(token)) return Promise.resolve(false); primeAuthorizationCookie(token); const task = (async () => { let deletedCount = 0; let gmSetCount = 0; if (gmCookieAvailable()) { const cookies = await gmCookieList({ name: 'Authorization' }); const targets = cookies.filter(cookie => { const domain = String(cookie.domain || '').replace(/^\./, ''); return !domain || domain === location.hostname || domain.endsWith(`.${location.hostname}`); }); await Promise.all(targets.map(async cookie => { const ok = await gmCookieDelete({ name: 'Authorization', domain: cookie.domain, path: cookie.path || '/' }); if (ok) deletedCount++; })); const expirationDate = Math.floor(Date.now() / 1000) + 7200; const results = await Promise.all(AUTH_COOKIE_PATHS.map(path => gmCookieSet({ url: `${ORIGIN}${path}`, name: 'Authorization', value: token, path, secure: true, httpOnly: false, expirationDate }))); gmSetCount = results.filter(Boolean).length; } // GM_cookie 操作期间可能清理/覆盖了页面 Cookie,最后再写一次当前 token。 setDocumentAuthorizationCookie(token); log('info', 'authorization-cookie-background-sync-finished', { tokenTime: readTokenTime(token), deletedCount, gmSetCount, gmCookieAvailable: gmCookieAvailable(), cookie: getDocumentAuthorizationCookieInfo() }); return true; })().catch(error => { log('warn', 'authorization-cookie-background-sync-failed', { error: error.message }); setDocumentAuthorizationCookie(token); return false; }); log('info', 'authorization-cookie-background-sync-started', { tokenTime: readTokenTime(token), message: '不阻塞 /elective/user;后台清理旧 Cookie。' }); return task; } async function syncAuthorizationCookie(token) { if (!validToken(token)) throw new Error('登录响应 token 格式异常'); setFlow('sync-cookie'); await clearAuthorizationCookies('after-login-before-write-latest'); let gmSetCount = 0; if (gmCookieAvailable()) { const expirationDate = Math.floor(Date.now() / 1000) + 7200; const results = await Promise.all(AUTH_COOKIE_PATHS.map(path => gmCookieSet({ url: `${ORIGIN}${path}`, name: 'Authorization', value: token, path, secure: true, httpOnly: false, expirationDate }))); gmSetCount = results.filter(Boolean).length; } setDocumentAuthorizationCookie(token); log('info', 'authorization-cookie-synced', { tokenTime: readTokenTime(token), gmCookieAvailable: gmCookieAvailable(), gmSetCount, after: getDocumentAuthorizationCookieInfo() }); } function normalizeBatch(item) { if (!item || typeof item !== 'object') return null; const batchId = String(item.code || item.batchId || item.id || '').trim(); if (!BATCH_ID_PATTERN.test(batchId)) return null; const noSelectReason = String(item.noSelectReason || item.reason || item.msg || '').trim(); const canSelect = String(item.canSelect) === '1' || item.canSelect === true || item.canSelect === 1; return { batchId, name: String(item.name || item.batchName || item.title || ''), schoolTerm: String(item.schoolTerm || item.schoolTermName || ''), beginTime: String(item.beginTime || ''), endTime: String(item.endTime || ''), canSelect, noSelectReason, noSelectCode: String(item.noSelectCode || ''), needConfirm: String(item.needConfirm || ''), isConfirmed: String(item.isConfirmed || ''), confirmInfo: String(item.confirmInfo || ''), raw: item }; } function getBatchList(loginJson) { const candidates = [ ['data.student.electiveBatchList', loginJson?.data?.student?.electiveBatchList], ['data.electiveBatchList', loginJson?.data?.electiveBatchList], ['student.electiveBatchList', loginJson?.student?.electiveBatchList], ['electiveBatchList', loginJson?.electiveBatchList] ].filter(([, value]) => Array.isArray(value)); const normalizedCandidates = candidates.map(([source, value]) => ({ source, raw: value, list: value.map(normalizeBatch).filter(Boolean) })); const selected = normalizedCandidates.find(item => item.source === 'data.student.electiveBatchList' && item.list.length > 0 ) || normalizedCandidates.find(item => item.list.length > 0) || { source: candidates[0]?.[0] || 'not-found', raw: candidates[0]?.[1] || [], list: [] }; const list = selected.list; log('info', 'batch-list-source', { source: selected.source, rawCount: selected.raw.length, validCount: list.length, invalidItems: selected.raw .filter(item => !normalizeBatch(item)) .map(item => summarize(item)), allArraySources: normalizedCandidates.map(item => ({ source: item.source, rawCount: item.raw.length, validCount: item.list.length, batchIds: item.list.map(batch => batch.batchId) })) }); const distinctBatchSets = [...new Set(normalizedCandidates .filter(item => item.list.length > 0) .map(item => item.list.map(batch => batch.batchId).join('|')))]; if (distinctBatchSets.length > 1) { log('warn', 'batch-list-sources-conflict', { selectedSource: selected.source, distinctBatchSets, rule: '优先使用 data.student.electiveBatchList;控制台已输出全部来源' }); } try { console.groupCollapsed(`[WHUT-SkipEntry] 选课轮次来源:${selected.source},共 ${list.length} 条`); console.table(list.map(formatBatchForLog)); console.groupEnd(); } catch (error) {} return list; } function formatBatchForLog(batch) { return { batchId: batch.batchId, name: batch.name, canSelect: batch.canSelect, noSelectReason: batch.noSelectReason, noSelectCode: batch.noSelectCode, schoolTerm: batch.schoolTerm, beginTime: batch.beginTime, endTime: batch.endTime, needConfirm: batch.needConfirm, isConfirmed: batch.isConfirmed, confirmInfo: batch.confirmInfo }; } function waitForDocumentBody() { if (document.body || document.documentElement) return Promise.resolve(); return new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve, { once: true }); }); } function chooseBatchByPrompt(batches, student) { return waitForDocumentBody().then(() => new Promise((resolve, reject) => { const overlay = document.createElement('div'); overlay.id = 'whut-skip-entry-batch-picker'; overlay.style.cssText = [ 'position:fixed', 'inset:0', 'z-index:2147483647', 'display:flex', 'align-items:center', 'justify-content:center', 'padding:20px', 'background:rgba(15,23,42,.58)', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' ].join(';'); const panel = document.createElement('div'); panel.style.cssText = [ 'width:min(680px,94vw)', 'max-height:86vh', 'overflow:auto', 'background:#fff', 'border-radius:14px', 'box-shadow:0 20px 60px rgba(0,0,0,.3)', 'padding:22px' ].join(';'); const title = document.createElement('div'); title.textContent = '选择进入的选课轮次'; title.style.cssText = 'font-size:20px;font-weight:800;color:#0f172a;margin-bottom:8px;'; const desc = document.createElement('div'); desc.textContent = `检测到 ${batches.length} 个可进入轮次${student?.campusName ? `,当前学生校区:${student.campusName}` : ''}。脚本不会猜测,请选择正确轮次。`; desc.style.cssText = 'font-size:13px;color:#475569;line-height:1.6;margin-bottom:14px;'; panel.append(title, desc); batches.forEach(batch => { const button = document.createElement('button'); button.type = 'button'; button.style.cssText = [ 'display:block', 'width:100%', 'margin:10px 0', 'padding:13px 15px', 'text-align:left', 'border:1px solid #cbd5e1', 'border-radius:10px', 'background:#f8fafc', 'color:#0f172a', 'cursor:pointer' ].join(';'); const heading = document.createElement('div'); heading.textContent = batch.name || batch.batchId; heading.style.cssText = 'font-size:14px;font-weight:800;margin-bottom:5px;'; const detail = document.createElement('div'); detail.textContent = `${batch.batchId} · ${batch.beginTime || '开始时间未知'} 至 ${batch.endTime || '结束时间未知'}`; detail.style.cssText = 'font-size:12px;color:#64748b;line-height:1.5;'; button.append(heading, detail); button.onmouseenter = () => { button.style.borderColor = '#2655c8'; button.style.background = '#eff6ff'; }; button.onmouseleave = () => { button.style.borderColor = '#cbd5e1'; button.style.background = '#f8fafc'; }; button.onclick = () => { log('info', 'batch-picker-selected', { selected: formatBatchForLog(batch) }); overlay.remove(); resolve(batch); }; panel.appendChild(button); }); const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = '取消自动进入'; cancel.style.cssText = 'margin-top:8px;padding:9px 14px;border:0;border-radius:8px;background:#e2e8f0;color:#334155;cursor:pointer;'; cancel.onclick = () => { log('warn', 'batch-picker-cancelled'); overlay.remove(); reject(new Error('用户取消选择选课轮次')); }; panel.appendChild(cancel); overlay.appendChild(panel); (document.body || document.documentElement).appendChild(overlay); log('info', 'batch-picker-shown', { batches: batches.map(formatBatchForLog) }); })); } async function chooseBatch(loginJson, providedBatches = null) { const batches = Array.isArray(providedBatches) ? providedBatches : getBatchList(loginJson); const selectable = batches.filter(batch => batch.canSelect && !batch.noSelectReason); const student = loginJson?.data?.student || loginJson?.student || {}; const campusName = String(student.campusName || '').trim(); log('info', 'login-batches', { student: { XH: student.XH, XM: student.XM, campusName: student.campusName, teachCampus: student.teachCampus }, all: batches.map(formatBatchForLog), selectable: selectable.map(formatBatchForLog), rejected: batches.filter(batch => !selectable.includes(batch)).map(formatBatchForLog) }); try { console.groupCollapsed('[WHUT-SkipEntry] 登录响应中的 batchId 判定结果'); console.table(batches.map(batch => ({ batchId: batch.batchId, name: batch.name, canSelect: batch.canSelect, noSelectReason: batch.noSelectReason, noSelectCode: batch.noSelectCode, beginTime: batch.beginTime, endTime: batch.endTime, selected: selectable.includes(batch) }))); console.groupEnd(); } catch (error) {} if (selectable.length === 0) { throw new Error(`登录成功,但没有可进入轮次。收到 ${batches.length} 个轮次,详情请看控制台 WHUT-SkipEntry 日志`); } if (selectable.length === 1) return selectable[0]; log('warn', 'multiple-selectable-batches-require-user-choice', { campusName, candidates: selectable.map(formatBatchForLog) }); return chooseBatchByPrompt(selectable, student); } function saveLoginSnapshot(loginJson, batches) { try { const student = loginJson?.data?.student || loginJson?.student || {}; const snapshot = { savedAt: Date.now(), tokenTime: readTokenTime(loginJson?.data?.token || loginJson?.token || ''), student: { XH: student.XH, XM: student.XM, campusName: student.campusName, teachCampus: student.teachCampus }, batches: batches.map(batch => formatBatchForLog(batch)) }; sessionStorage.setItem('WHUT_SKIP_ENTRY_LOGIN_SNAPSHOT', JSON.stringify(snapshot)); log('info', 'login-snapshot-saved', { savedAt: snapshot.savedAt, tokenTime: snapshot.tokenTime, batchIds: snapshot.batches.map(batch => batch.batchId) }); } catch (error) { log('warn', 'login-snapshot-save-failed', { error: error.message }); } } function loadLoginSnapshot() { try { const snapshot = JSON.parse(sessionStorage.getItem('WHUT_SKIP_ENTRY_LOGIN_SNAPSHOT') || 'null'); if (!snapshot || !Array.isArray(snapshot.batches)) return null; const batches = snapshot.batches.map(normalizeBatch).filter(Boolean); if (batches.length === 0) return null; return { ...snapshot, batches }; } catch (error) { log('warn', 'login-snapshot-load-failed', { error: error.message }); return null; } } function isProfileIndexPage() { const href = pageWindow.location?.href || location.href; return /\/xsxk\/profile\/index(?:\.html)?(?:[?#]|$)/i.test(href); } function collectBatchesFromPageObjects() { const found = []; const visited = new WeakSet(); const add = (source, value) => { if (!Array.isArray(value)) return; const batches = value.map(normalizeBatch).filter(Boolean); if (batches.length > 0) found.push({ source, batches }); }; const visit = (value, path, depth) => { if (!value || typeof value !== 'object' || depth > 5 || visited.has(value)) return; visited.add(value); if (Array.isArray(value)) { value.slice(0, 80).forEach((item, index) => visit(item, `${path}[${index}]`, depth + 1)); return; } Object.entries(value).slice(0, 120).forEach(([key, child]) => { const nextPath = path ? `${path}.${key}` : key; if (/electiveBatchList/i.test(key)) add(nextPath, child); if (depth < 5 && /student|user|profile|login|xsxk|batch|app|vue|vm|store|state|config|root|info|data/i.test(key)) { try { visit(child, nextPath, depth + 1); } catch (error) {} } }); }; const rootKeys = Object.keys(pageWindow) .filter(key => /student|user|profile|login|xsxk|batch|app|vue|vm|store|state|config|root|info|data/i.test(key)) .slice(0, 120); rootKeys.forEach(key => { try { visit(pageWindow[key], key, 0); } catch (error) {} }); return found; } function collectBatchesFromPageText() { const text = String(document.documentElement?.innerHTML || '').slice(0, 2000000); const found = []; const objectPattern = /\{[^{}]{0,5000}(?:[\\"']?(?:code|batchId)[\\"']?\s*:\s*[\\"']?[A-Za-z0-9_-]{8,100}[\\"']?)[^{}]{0,5000}\}/gi; let match; while ((match = objectPattern.exec(text)) && found.length < 100) { const fragment = match[0]; const context = text.slice(match.index, match.index + 12000); const idMatch = fragment.match(/[\\"']?(?:code|batchId)[\\"']?\s*:\s*[\\"']?([A-Za-z0-9_-]{8,100})[\\"']?/i); if (!idMatch) continue; const readFrom = (source, key) => { const valueMatch = source.match(new RegExp( `[\\"']?${key}[\\"']?\\s*:\\s*(?:[\\"']([^\\"']*)[\\"']|([^,}\\s]+))`, 'i' )); return valueMatch?.[1] ?? valueMatch?.[2] ?? ''; }; const read = key => readFrom(fragment, key) || readFrom(context, key); const canSelect = read('canSelect'); found.push(normalizeBatch({ code: idMatch[1], name: read('name'), canSelect, noSelectReason: read('noSelectReason'), noSelectCode: read('noSelectCode'), beginTime: read('beginTime'), endTime: read('endTime'), confirmInfo: read('confirmInfo') })); } return found.filter(Boolean); } function getPageLoginSnapshot() { const objectSources = collectBatchesFromPageObjects(); if (objectSources.length > 0) { const selected = objectSources[0]; log('info', 'page-batch-source-found', { source: selected.source, batchIds: selected.batches.map(batch => batch.batchId), allSources: objectSources.map(item => ({ source: item.source, batchIds: item.batches.map(batch => batch.batchId) })) }); return { savedAt: Date.now(), tokenTime: 0, student: {}, batches: selected.batches }; } const textBatches = collectBatchesFromPageText(); if (textBatches.length > 0) { log('info', 'page-batch-text-source-found', { batchIds: textBatches.map(batch => batch.batchId), batches: textBatches.map(formatBatchForLog) }); return { savedAt: Date.now(), tokenTime: 0, student: {}, batches: textBatches }; } log('info', 'page-batch-source-not-found'); return null; } async function tryAutoEnterFromCachedSession() { if (!isProfileIndexPage() || cachedSessionFlowStarted || handlingLogin) return; cachedSessionFlowStarted = true; setFlow('checking-cached-session'); const snapshot = loadLoginSnapshot() || getPageLoginSnapshot(); if (!snapshot) { cachedSessionFlowStarted = false; log('info', 'cached-session-not-found', { message: '当前页面没有新登录响应;没有可恢复的登录轮次快照。' }); return; } const token = await readLatestAuthorizationToken(); if (!token) { cachedSessionFlowStarted = false; log('warn', 'cached-session-token-not-found', { snapshotTokenTime: snapshot.tokenTime }); toast('未读取到当前登录 Cookie,等待网页自身登录流程', 'warn'); return; } const currentTokenTime = readTokenTime(token); if (snapshot.tokenTime && currentTokenTime && currentTokenTime !== snapshot.tokenTime) { cachedSessionFlowStarted = false; log('warn', 'cached-session-stale', { snapshotTokenTime: snapshot.tokenTime, currentTokenTime, message: '当前 Cookie 与轮次快照不是同一次登录,拒绝使用旧 batchId。' }); return; } const selectable = snapshot.batches.filter(batch => batch.canSelect && !batch.noSelectReason); if (selectable.length === 0) { cachedSessionFlowStarted = false; log('warn', 'cached-session-no-selectable-batch', { batches: snapshot.batches.map(formatBatchForLog) }); return; } handlingLogin = true; handledToken = token; try { const batch = selectable.length === 1 ? selectable[0] : await chooseBatchByPrompt(selectable, snapshot.student || {}); log('info', 'cached-session-batch-selected', { selected: formatBatchForLog(batch), currentTokenTime }); toast(`检测到已登录轮次,正在直接进入:${batch.name || batch.batchId}`, 'info'); const cookieSync = startBackgroundAuthorizationCookieSync(token); await postElectiveUser(token, batch); await Promise.race([ cookieSync, new Promise(resolve => setTimeout(resolve, 250)) ]); primeAuthorizationCookie(token); toast('已跳过确认等待,正在进入选课界面...', 'success'); await enterGrabLessons(batch); } catch (error) { handlingLogin = false; if (error?.code === 'WHUT_AUTH_REQUIRED') { unauthenticatedDetected = true; cachedSessionFlowStarted = false; setFlow('awaiting-login'); log('info', 'cached-session-waiting-for-login', { message: '缓存轮次验证失败,当前页面需要用户完成验证码登录。' }); showLoginRequiredToast(); return; } setFlow('cached-session-failed'); toast(`自动进入失败:${error.message}`, 'error'); log('error', 'cached-session-flow-failed', { error: error.message, snapshotTokenTime: snapshot.tokenTime, currentTokenTime, cookie: getDocumentAuthorizationCookieInfo() }); } } async function postElectiveUser(token, batch) { setFlow('confirm-batch'); const requestBody = new URLSearchParams({ batchId: batch.batchId }).toString(); log('info', 'calling-elective-user', { url: ELECTIVE_USER_URL, batchId: batch.batchId, name: batch.name, requestBody, tokenTime: readTokenTime(token), cookie: getDocumentAuthorizationCookieInfo() }); const response = await withTimeout(nativeFetch(ELECTIVE_USER_URL, { method: 'POST', credentials: 'include', cache: 'no-store', headers: { 'Accept': 'application/json, text/plain, */*', 'Authorization': token, 'Content-Type': 'application/x-www-form-urlencoded', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' }, body: requestBody }), REQUEST_TIMEOUT, '/elective/user 请求超时'); const contentType = response.headers.get('content-type') || ''; const redirectedToProfile = /\/xsxk\/profile\/index(?:\.html)?/i.test(response.url || ''); log('info', 'elective-user-response-meta', { status: response.status, ok: response.ok, redirected: response.redirected, responseUrl: response.url, contentType, redirectedToProfile }); const responseText = await withTimeout(response.text(), REQUEST_TIMEOUT, '/elective/user 响应读取超时'); if (redirectedToProfile || !contentType.includes('application/json')) { const authenticationRequired = redirectedToProfile || /.*(?:登录|选课系统)/i.test(responseText.slice(0, 3000)); log(authenticationRequired ? 'info' : 'warn', authenticationRequired ? 'elective-user-authentication-required' : 'elective-user-non-json', { status: response.status, responseUrl: response.url, contentType, authenticationRequired, responsePreview: redactText(responseText.slice(0, 600)) }); if (authenticationRequired) { unauthenticatedDetected = true; throw createAuthenticationRequiredError('当前会话未登录,/elective/user 被重定向到 profile/index.html'); } throw new Error(`/elective/user 未返回 JSON:HTTP ${response.status},可能是 Authorization Cookie 冲突或登录失效`); } let json; try { json = JSON.parse(responseText); } catch (error) { log('error', 'elective-user-json-parse-failed', { responsePreview: redactText(responseText.slice(0, 600)), error: error.message }); throw new Error('/elective/user 返回内容不是合法 JSON'); } const code = Number(json?.code); const msg = String(json?.msg || json?.message || ''); const ok = response.ok && (code === 200 || /success|成功|操作成功/.test(msg)); log(ok ? 'info' : 'error', 'elective-user-response-body', { code: json?.code, msg, ok, dataKeys: json?.data && typeof json.data === 'object' ? Object.keys(json.data) : [], response: summarize(json) }); if (!ok) throw new Error(msg || `/elective/user 失败:HTTP ${response.status}`); const returnedBatches = getBatchList(json); const returnedTarget = returnedBatches.find(item => item.batchId === batch.batchId); if (returnedTarget && (!returnedTarget.canSelect || returnedTarget.noSelectReason)) { throw new Error(returnedTarget.noSelectReason || `轮次不可进入:${batch.batchId}`); } if (returnedBatches.length > 0 && !returnedTarget) { log('warn', 'elective-user-target-not-in-response', { requested: formatBatchForLog(batch), returned: returnedBatches.map(formatBatchForLog) }); } log('info', 'elective-user-success', { code: json.code, msg, batchId: batch.batchId }); return json; } async function enterGrabLessons(batch) { setFlow('navigate-grablessons'); const url = `${GRABLESSONS_URL}?batchId=${encodeURIComponent(batch.batchId)}`; log('info', 'navigating-to-grablessons', { url, batchId: batch.batchId, name: batch.name }); location.replace(url); } async function handleLoginJson(loginJson, source) { if (handlingLogin) { log('info', 'login-response-ignored-flow-busy', { source }); return; } const code = Number(loginJson?.code); const msg = String(loginJson?.msg || ''); const token = String(loginJson?.data?.token || loginJson?.token || ''); log('info', 'login-response-received', { source, code: loginJson?.code, msg, hasToken: Boolean(token), tokenTime: readTokenTime(token), batchCount: Array.isArray(loginJson?.data?.student?.electiveBatchList) ? loginJson.data.student.electiveBatchList.length : null, responseShape: { topKeys: loginJson && typeof loginJson === 'object' ? Object.keys(loginJson) : [], dataKeys: loginJson?.data && typeof loginJson.data === 'object' ? Object.keys(loginJson.data) : [], studentKeys: loginJson?.data?.student && typeof loginJson.data.student === 'object' ? Object.keys(loginJson.data.student) : [] } }); if (code !== 200 || !validToken(token)) { log('info', 'login-response-ignored', { source, code: loginJson?.code, msg, hasToken: Boolean(token) }); return; } handlingLogin = true; handledToken = token; try { setFlow('login-success'); log('info', 'login-success-captured', { source, msg }); toast('登录成功,正在选择选课轮次...', 'success'); const loginBatches = getBatchList(loginJson); saveLoginSnapshot(loginJson, loginBatches); const selectedBatch = await chooseBatch(loginJson, loginBatches); log('info', 'batch-selected-final', { selected: formatBatchForLog(selectedBatch), source, handledTokenTime: readTokenTime(handledToken) }); toast(`已选择轮次:${selectedBatch.name || selectedBatch.batchId}`, 'success'); toast('正在准备最新登录凭据并确认选课轮次...', 'info'); const cookieSync = startBackgroundAuthorizationCookieSync(token); toast('正在确认选课轮次...', 'info'); await postElectiveUser(token, selectedBatch); await Promise.race([ cookieSync, new Promise(resolve => setTimeout(resolve, 250)) ]); primeAuthorizationCookie(token); toast('确认成功,正在进入选课界面...', 'success'); await enterGrabLessons(selectedBatch); } catch (error) { handlingLogin = false; setFlow('failed'); toast(`登录后自动进入选课页失败:${error.message}`, 'error'); log('error', 'api-login-flow-failed', { source, error: error.message, tokenTime: readTokenTime(handledToken), cookie: getDocumentAuthorizationCookieInfo() }); } } function patchFetch(target = pageWindow, label = 'pageWindow') { if (!target || typeof target.fetch !== 'function' || target.fetch.__whutSkipEntryApiOnly) { log('warn', 'fetch-patch-skipped', { label, hasTarget: Boolean(target), hasFetch: Boolean(target?.fetch), alreadyPatched: Boolean(target?.fetch?.__whutSkipEntryApiOnly) }); return false; } const originalFetch = target.fetch.bind(target); const wrapped = async function(input, init) { const requestUrl = typeof input === 'string' ? input : input?.url; const requestMethod = String(init?.method || input?.method || 'GET').toUpperCase(); try { const headers = new Headers(init?.headers || input?.headers || {}); const authorization = headers.get('authorization'); if (validToken(authorization)) latestObservedToken = authorization; } catch (error) {} const apiPath = getApiPath(requestUrl); if (apiPath && (/auth|elective\/user|web\/now/i.test(apiPath))) { log('info', 'fetch-entry-api-request-observed', { label, requestMethod, apiPath }); } if (isAuthCaptchaPath(apiPath)) { markCaptchaObserved(`fetch:${label}`, { apiPath }); } if (isLoginUrl(requestUrl)) { loginRequestObserved = true; unauthenticatedDetected = false; loginWaitToastShown = false; dismissLoginRequiredToast(); expireDocumentAuthorizationCookies(); log('info', 'fetch-login-request-observed', { label, action: '仅同步清理 document.cookie 中的旧 Authorization;不等待 GM_cookie,避免阻塞登录请求。', requestUrl, requestMethod, cookieBeforeLogin: getDocumentAuthorizationCookieInfo() }); toast('检测到登录请求,等待登录结果...', 'info'); } const response = await originalFetch(input, init); const url = response.url || (typeof input === 'string' ? input : input?.url); if (apiPath && (/auth|elective\/user|web\/now/i.test(apiPath))) { log('info', 'fetch-entry-api-response-observed', { label, apiPath, status: response.status, responseUrl: response.url }); } if (isLoginUrl(url)) { log('info', 'fetch-login-response-meta', { label, status: response.status, ok: response.ok, redirected: response.redirected, responseUrl: response.url, contentType: response.headers.get('content-type') || '' }); response.clone().text() .then(text => { log('info', 'fetch-login-response-body-preview', { label, length: text.length, preview: redactText(text.slice(0, 800)) }); let json; try { json = JSON.parse(text); } catch (error) { throw new Error(`登录响应不是 JSON:${error.message}`); } return handleLoginJson(json, `fetch:${label}:/xsxk/auth/login`); }) .catch(error => log('warn', 'login-fetch-json-parse-failed', { error: error.message })); } return response; }; Object.defineProperty(wrapped, '__whutSkipEntryApiOnly', { value: true }); target.fetch = wrapped; log('info', 'fetch-patched', { label }); return true; } function patchXHR(target = pageWindow, label = 'pageWindow') { const XHR = target?.XMLHttpRequest; const proto = XHR?.prototype; if (!proto || proto.__whutSkipEntryApiOnly) { log('warn', 'xhr-patch-skipped', { label, hasTarget: Boolean(target), hasXHR: Boolean(XHR), alreadyPatched: Boolean(proto?.__whutSkipEntryApiOnly) }); return false; } const open = proto.open; const send = proto.send; const originalSetHeader = proto.setRequestHeader; proto.open = function(method, url) { this.__whutSkipEntryUrl = String(url || ''); this.__whutSkipEntryMethod = String(method || 'GET').toUpperCase(); return open.apply(this, arguments); }; proto.setRequestHeader = function(name, value) { if (String(name || '').toLowerCase() === 'authorization' && validToken(String(value || ''))) { latestObservedToken = String(value); log('info', 'xhr-authorization-observed', { label, tokenTime: readTokenTime(latestObservedToken), requestUrl: this.__whutSkipEntryUrl || '' }); } return originalSetHeader.apply(this, arguments); }; proto.send = function() { const apiPath = getApiPath(this.__whutSkipEntryUrl); if (apiPath && (/auth|elective\/user|web\/now/i.test(apiPath))) { log('info', 'xhr-entry-api-request-observed', { label, method: this.__whutSkipEntryMethod, apiPath }); this.addEventListener('readystatechange', function() { if (this.readyState !== 4) return; log('info', 'xhr-entry-api-response-observed', { label, apiPath, status: this.status, responseURL: this.responseURL, responseLength: getXhrResponseText(this).length }); }, { once: true }); } if (isAuthCaptchaPath(apiPath)) { markCaptchaObserved(`xhr:${label}`, { apiPath }); } if (isLoginUrl(this.__whutSkipEntryUrl)) { const xhr = this; const args = arguments; loginRequestObserved = true; unauthenticatedDetected = false; loginWaitToastShown = false; dismissLoginRequiredToast(); expireDocumentAuthorizationCookies(); log('info', 'xhr-login-request-observed', { label, action: '仅同步清理 document.cookie 中的旧 Authorization;立即放行登录请求,不等待 GM_cookie。', requestUrl: this.__whutSkipEntryUrl, requestMethod: this.__whutSkipEntryMethod, bodyType: args[0]?.constructor?.name || typeof args[0], bodyLength: typeof args[0] === 'string' ? args[0].length : 0, cookieBeforeLogin: getDocumentAuthorizationCookieInfo() }); toast('检测到登录请求,等待登录结果...', 'info'); let loginResponseHandled = false; const processLoginResponse = function() { if (loginResponseHandled || this.readyState !== 4) return; loginResponseHandled = true; log('info', 'xhr-login-response-meta', { label, status: this.status, responseURL: this.responseURL, responseType: this.responseType || '', responseLength: getXhrResponseText(this).length }); try { const responseType = this.responseType || ''; const json = responseType === 'json' && this.response ? this.response : JSON.parse(getXhrResponseText(this) || '{}'); handleLoginJson(json, `xhr:${label}:/xsxk/auth/login`); } catch (error) { log('warn', 'login-xhr-json-parse-failed', { error: error.message, responsePreview: redactText(getXhrResponseText(this).slice(0, 600)) }); } }; // 部分页面只可靠触发 loadend,不能只依赖 readystatechange。 this.addEventListener('readystatechange', processLoginResponse); this.addEventListener('loadend', processLoginResponse, { once: true }); return send.apply(xhr, args); } return send.apply(this, arguments); }; Object.defineProperty(proto, '__whutSkipEntryApiOnly', { value: true }); log('info', 'xhr-patched', { label }); return true; } try { exposeDiagnostics(); log('info', 'script-context', { href: pageWindow.location?.href || location.href, readyState: document.readyState, pageWindowIsSandboxWindow: pageWindow === window, hasPageFetch: typeof pageWindow.fetch === 'function', hasPageXHR: Boolean(pageWindow.XMLHttpRequest), gmCookieAvailable: gmCookieAvailable(), documentCookie: getDocumentAuthorizationCookieInfo() }); patchFetch(pageWindow, 'pageWindow'); patchXHR(pageWindow, 'pageWindow'); if (pageWindow !== window) { patchFetch(window, 'sandboxWindow'); patchXHR(window, 'sandboxWindow'); } log('info', 'api-only-login-flow-ready', { message: '登录前不跳转;仅捕获 /xsxk/auth/login 成功响应后执行 /elective/user -> grablessons。', loginPath: LOGIN_PATH, currentPath: location.pathname }); if (isProfileIndexPage()) { log('info', 'profile-cached-entry-scheduled', { initialDelayMs: PROFILE_AUTO_ENTRY_INITIAL_DELAY, intervalMs: 500, maxAttempts: 10, message: '仅在确认当前页面不是登录/验证码页面后,才使用本会话保存的轮次快照直接确认 /elective/user。' }); let attempts = 0; setTimeout(() => { if (!isProfileIndexPage() || loginRequestObserved || handlingLogin || authCaptchaObserved || hasLoginFormVisible()) { log('info', 'profile-cached-entry-skipped-before-start', { loginRequestObserved, handlingLogin, authCaptchaObserved, loginFormVisible: hasLoginFormVisible() }); if (authCaptchaObserved || hasLoginFormVisible()) showLoginRequiredToast(); return; } profileAutoEntryTimer = setInterval(() => { attempts++; if ( !isProfileIndexPage() || loginRequestObserved || handlingLogin || authCaptchaObserved || unauthenticatedDetected || attempts > 10 ) { clearInterval(profileAutoEntryTimer); profileAutoEntryTimer = null; log('info', 'profile-cached-entry-skipped', { loginRequestObserved, handlingLogin, authCaptchaObserved, unauthenticatedDetected, attempts }); if (authCaptchaObserved || unauthenticatedDetected) showLoginRequiredToast(); return; } tryAutoEnterFromCachedSession(); }, 500); }, PROFILE_AUTO_ENTRY_INITIAL_DELAY); } } catch (error) { console.error('[WHUT-SkipEntry] 初始化失败', error); log('error', 'script-initialization-failed', { error: error.message, stack: error.stack }); toast(`跳过脚本初始化失败:${error.message}`, 'error'); } })();