// ==UserScript== // @name CTT MOOC 自动学习助手 (可拖动+H5修复版) // @namespace https://mooc.ctt.cn/ // @version 0.2.5 // @description 在 CTT MOOC 上自动按顺序学习未完成课程,自动播放视频、切换小节,支持H5内嵌测验自动处理,面板可拖动,支持跳过卡死章节。 // @author pppm // @match https://mooc.ctt.cn/* // @run-at document-idle // @grant none // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/557167/CTT%20MOOC%20%E8%87%AA%E5%8A%A8%E5%AD%A6%E4%B9%A0%E5%8A%A9%E6%89%8B%20%28%E5%8F%AF%E6%8B%96%E5%8A%A8%2BH5%E4%BF%AE%E5%A4%8D%E7%89%88%29.user.js // @updateURL https://update.greasyfork.icu/scripts/557167/CTT%20MOOC%20%E8%87%AA%E5%8A%A8%E5%AD%A6%E4%B9%A0%E5%8A%A9%E6%89%8B%20%28%E5%8F%AF%E6%8B%96%E5%8A%A8%2BH5%E4%BF%AE%E5%A4%8D%E7%89%88%29.meta.js // ==/UserScript== (function () { 'use strict'; // ---------------------------- // Config & State // ---------------------------- const LS_KEYS = { running: 'ctt_mooc_auto_running', queue: 'ctt_mooc_course_queue', cfg: 'ctt_mooc_cfg', navLock: 'ctt_mooc_nav_lock', resumeAt: 'ctt_mooc_resume_at', courseEnterAt: 'ctt_mooc_course_enter_at', courseEnterUrl: 'ctt_mooc_course_enter_url', currentId: 'ctt_mooc_current_id', panelPos: 'ctt_mooc_panel_pos' // 保存面板位置 }; const defaultCfg = { mute: false, log: true, clickDelayMs: 800, nextDelayMs: 1500, returnDelayMs: 3000, beforeNextCourseDelayMs: 2500, pageSettleMs: 5000, studyRefreshMinutes: 10, autoAnswer: true }; function readCfg() { try { return { ...defaultCfg, ...(JSON.parse(localStorage.getItem(LS_KEYS.cfg) || '{}')) }; } catch (_) { return { ...defaultCfg }; } } function writeCfg(cfg) { localStorage.setItem(LS_KEYS.cfg, JSON.stringify(cfg)); } function isRunning() { return localStorage.getItem(LS_KEYS.running) === '1'; } function setRunning(v) { if (v) localStorage.setItem(LS_KEYS.running, '1'); else localStorage.removeItem(LS_KEYS.running); } function getQueue() { try { return JSON.parse(localStorage.getItem(LS_KEYS.queue) || '[]'); } catch { return []; } } function setQueue(arr) { localStorage.setItem(LS_KEYS.queue, JSON.stringify(arr || [])); } // ... (URL & Course Helpers - unchanged) ... function humanizeCourse(u) { try { const url = new URL(u, location.href); const seg = (url.hash || url.pathname).split('/').filter(Boolean).pop() || u; return decodeURIComponent(seg).slice(0, 60); } catch { return u; } } function extractCourseIdFromUrl(u) { try { const url = new URL(u, location.href); const hash = url.hash || ''; const m = hash.match(/course\/detail\/[^&?#]+&([A-Za-z0-9-]+)/); if (m) return m[1]; const segs = (hash || url.pathname).split('/').filter(Boolean); const last = segs.pop() || ''; const m2 = last.match(/([A-Za-z0-9-]{8,})$/); if (m2) return m2[1]; } catch {} return String(u||''); } function extractCourseTitleFromStudyPage() { const cands = ['.course-title .text-overflow[title]', '.course-title', '.title .text-overflow[title]', '.text-overflow[title]', 'h1', 'h2', 'h3']; for (const sel of cands) { const el = document.querySelector(sel); if (el) { const t = (el.getAttribute('title') || el.textContent || '').trim(); if (t) return t; } } return ''; } function getCurrentCourseItem() { try { const url = location.href; if (!/\/course\/detail\//.test(url)) return null; const id = extractCourseIdFromUrl(url); if (!id) return null; const title = extractCourseTitleFromStudyPage() || document.title || humanizeCourse(url); return { id, url, title, status: 'pending' }; } catch (_) { return null; } } function normalizeQueueItem(item) { if (!item) return null; if (typeof item === 'string') return { id: extractCourseIdFromUrl(item), url: item, title: humanizeCourse(item), status: 'pending' }; if (item.url) return { id: item.id || extractCourseIdFromUrl(item.url), url: item.url, title: item.title || humanizeCourse(item.url), status: item.status || 'pending' }; return null; } function getItemUrl(item) { const it = normalizeQueueItem(item); return it ? it.url : ''; } function getItemTitle(item) { const it = normalizeQueueItem(item); return it ? it.title : ''; } function getItemId(item) { const it = normalizeQueueItem(item); return it ? it.id : ''; } function normalizeQueueArray(arr) { return (arr || []).map(normalizeQueueItem).filter(Boolean); } function enqueueUnique(newItems) { const q = normalizeQueueArray(getQueue()); const keys = new Set(q.map(getItemId)); let added = 0; for (const raw of (newItems || [])) { const it = normalizeQueueItem(raw); if (!it) continue; const k = it.id; if (!k || keys.has(k)) continue; q.push(it); keys.add(k); added++; } setQueue(q); return { added, total: q.length }; } function getCurrentId() { try { return localStorage.getItem(LS_KEYS.currentId) || ''; } catch { return ''; } } function setCurrentId(id) { try { if (id) localStorage.setItem(LS_KEYS.currentId, id); } catch {} } function clearCurrentId() { try { localStorage.removeItem(LS_KEYS.currentId); } catch {} } function migrateCurrentUrlToId() { try { const legacy = localStorage.getItem(LS_KEYS.currentUrl); // cleanup legacy if (legacy && !localStorage.getItem(LS_KEYS.currentId)) { const id = extractCourseIdFromUrl(legacy); if (id) localStorage.setItem(LS_KEYS.currentId, id); } localStorage.removeItem(LS_KEYS.currentUrl); } catch {} } function markInProgress(url) { const q = normalizeQueueArray(getQueue()); let found = false; const id = extractCourseIdFromUrl(url); for (const it of q) { if (it.id === id) { it.status = 'in_progress'; found = true; } else if (it.status === 'in_progress') { it.status = 'pending'; } } if (found) { setQueue(q); setCurrentId(id); renderQueueSummary(); } } function completeCurrentAndDequeue() { const cur = getCurrentId(); if (!cur) return false; const q = normalizeQueueArray(getQueue()); let updated = false; for (const it of q) { if (it.id === cur) { it.status = 'completed'; updated = true; } else if (it.status === 'in_progress') { it.status = 'pending'; } } if (updated) { setQueue(q); clearCurrentId(); renderQueueSummary(); } return updated; } function renderQueueSummary() { const q = getQueue(); const cnt = document.getElementById('ctt-queue-count'); const list = document.getElementById('ctt-queue-list'); if (!cnt || !list) return; cnt.textContent = String(q.length); if (!q.length) { list.innerHTML = '
(空)
'; return; } const cur = getCurrentId(); const items = q.slice(0, 20).map((it, i) => { const url = getItemUrl(it); const title = getItemTitle(it); const norm = normalizeQueueItem(it); const inProg = (norm.status === 'in_progress') || (getItemId(it) === cur); const completed = norm.status === 'completed'; const color = completed ? '#ef4444' : (inProg ? '#f59e0b' : '#2563eb'); const tag = completed ? ' (已完成)' : (inProg ? ' (进行中)' : ''); return `
${i+1}. ${title}${tag}
`; }).join(''); const more = q.length > 20 ? `
…… 其余 ${q.length-20} 项
` : ''; list.innerHTML = items + more; } // ---------------------------- // UI: Floating Control & CSS Fixes (Draggable) // ---------------------------- const CSS = ` .ctt-helper-wrap{position:fixed;z-index:999999;font-family:system-ui,-apple-system,sans-serif;user-select:none;} .ctt-helper-btn{background:#3b82f6;color:#fff;border:none;border-radius:20px;padding:8px 12px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15);transition:background 0.2s;} .ctt-helper-btn:hover{filter:brightness(1.1);} .ctt-helper-btn.stop{background:#ef4444} .ctt-helper-panel{margin-top:8px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:8px 10px;box-shadow:0 4px 16px rgba(0,0,0,.15);min-width:230px;} .ctt-helper-row{display:flex;align-items:center;justify-content:space-between;margin:6px 0} .ctt-helper-row label{font-size:12px;color:#111827;cursor:pointer;} .ctt-badge{position:fixed;left:16px;bottom:24px;background:#111827;color:#e5e7eb;border-radius:6px;padding:6px 8px;font-size:12px;z-index:999999;opacity:.9;pointer-events:none;} /* Drag handle style */ .ctt-drag-handle { cursor: move; padding: 4px; background: #f3f4f6; border-radius: 4px; margin-bottom: 6px; text-align: center; color: #6b7280; font-size: 10px; font-weight: bold; } /* H5 Fixes */ iframe { pointer-events: auto !important; } `; function injectStyle() { if (document.getElementById('ctt-helper-style')) return; const s = document.createElement('style'); s.id = 'ctt-helper-style'; s.textContent = CSS; document.head.appendChild(s); } function logBadge(msg) { const cfg = readCfg(); if (!cfg.log) return; let el = document.getElementById('ctt-helper-badge'); if (!el) { el = document.createElement('div'); el.id = 'ctt-helper-badge'; el.className = 'ctt-badge'; document.body.appendChild(el); } el.textContent = `[自动学习] ${msg}`; } function debugLog(msg, extra) { try { const ts = new Date().toISOString().split('T')[1].replace('Z',''); console.log(`[CTT DEBUG ${ts}] ${msg}`, extra || ''); } catch (_) {} logBadge(msg); } // 面板拖拽逻辑 function makeDraggable(el, handle) { let isDragging = false; let startX, startY, initialLeft, initialTop; handle.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; el.style.right = 'auto'; // 清除默认 right el.style.bottom = 'auto'; // 清除默认 bottom el.style.left = initialLeft + 'px'; el.style.top = initialTop + 'px'; handle.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; el.style.left = `${initialLeft + dx}px`; el.style.top = `${initialTop + dy}px`; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; handle.style.cursor = 'move'; // 保存位置 try { const rect = el.getBoundingClientRect(); localStorage.setItem(LS_KEYS.panelPos, JSON.stringify({ left: rect.left, top: rect.top })); } catch {} } }); } function renderUI() { injectStyle(); let wrap = document.getElementById('ctt-helper-wrap'); if (wrap) return; wrap = document.createElement('div'); wrap.id = 'ctt-helper-wrap'; wrap.className = 'ctt-helper-wrap'; // 恢复位置 try { const pos = JSON.parse(localStorage.getItem(LS_KEYS.panelPos)); if (pos && pos.left) { wrap.style.left = pos.left + 'px'; wrap.style.top = pos.top + 'px'; } else { wrap.style.right = '16px'; wrap.style.bottom = '24px'; } } catch { wrap.style.right = '16px'; wrap.style.bottom = '24px'; } const btn = document.createElement('button'); btn.id = 'ctt-helper-toggle'; btn.className = 'ctt-helper-btn'; wrap.appendChild(btn); const panel = document.createElement('div'); panel.className = 'ctt-helper-panel'; panel.innerHTML = `
::: 按住此处拖动 :::
`; wrap.appendChild(panel); document.body.appendChild(wrap); // 绑定拖拽 makeDraggable(wrap, document.getElementById('ctt-drag-handle')); const cfg = readCfg(); const muteEl = document.getElementById('ctt-mute'); const autoAnswerEl = document.getElementById('ctt-auto-answer'); const toggleBtn = document.getElementById('ctt-helper-toggle'); muteEl.checked = !!cfg.mute; autoAnswerEl.checked = cfg.autoAnswer !== false; function refreshBtn() { if (isRunning()) { toggleBtn.textContent = '停止自动学习'; toggleBtn.classList.add('stop'); } else { toggleBtn.textContent = '开始自动学习'; toggleBtn.classList.remove('stop'); } } refreshBtn(); toggleBtn.addEventListener('click', () => { if (isRunning()) { setRunning(false); logBadge('已停止'); } else { setRunning(true); logBadge('已启动'); tick(); } refreshBtn(); }); muteEl.addEventListener('change', () => { writeCfg({ ...readCfg(), mute: !!muteEl.checked }); applyVideoPrefs(); }); autoAnswerEl.addEventListener('change', () => { writeCfg({ ...readCfg(), autoAnswer: !!autoAnswerEl.checked }); }); document.getElementById('ctt-scan').addEventListener('click', () => { const found = collectCourseLinks(); if (atStudyPage()) { const curItem = getCurrentCourseItem(); if (curItem) found.unshift(curItem); } const { added, total } = enqueueUnique(found); renderQueueSummary(); alert(`新增课程 ${added} 个(队列共 ${total} 个)`); }); document.getElementById('ctt-clearq').addEventListener('click', () => { setQueue([]); renderQueueSummary(); alert('已清空队列'); }); // [新增] 强制跳过功能 document.getElementById('ctt-skip').addEventListener('click', async () => { if (!confirm('确定要强制跳过当前章节吗?这可能会导致当前章节未完成,但可以继续后续学习。')) return; debugLog('用户请求强制跳过'); if (await goNextLesson(true)) { debugLog('强制跳过成功'); } else { // 如果点不到下一节,尝试直接切下一个课程 completeCurrentAndDequeue(); await goNextCourseOrIndex(); } }); document.getElementById('ctt-copyq').addEventListener('click', async () => { const q = getQueue(); const text = q.map(it => `${getItemTitle(it)}\t${getItemUrl(it)}`).join('\n'); try { await navigator.clipboard.writeText(text); alert('已复制'); } catch { alert('复制失败'); } }); renderQueueSummary(); } // ---------------------------- // Helpers // ---------------------------- function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function onHashChange(cb) { window.addEventListener('hashchange', cb, false); } function nowRoute() { return location.hash || '#'; } function atCourseIndex() { return nowRoute().includes('#/study/course/index'); } function atBranchIndex() { return nowRoute().includes('#/study/branch/index'); } function atSubjectDetail() { return nowRoute().includes('#/study/subject/detail'); } function atAnyCourseListIndex() { return atCourseIndex() || atBranchIndex() || atSubjectDetail(); } function atStudyPage() { return /#\/study\//.test(nowRoute()); } let studyRefreshInterval = null; function startStudyRefresh() { if (studyRefreshInterval) return; const ms = Math.max(1, readCfg().studyRefreshMinutes) * 60 * 1000; studyRefreshInterval = setInterval(() => { try { debugLog('页面定时刷新保活'); } catch {} location.reload(); }, ms); } function stopStudyRefresh() { if (studyRefreshInterval) { try { clearInterval(studyRefreshInterval); } catch {} studyRefreshInterval = null; } } function pageJustEnteredCourse() { try { const ts = +localStorage.getItem(LS_KEYS.courseEnterAt) || 0; const url = localStorage.getItem(LS_KEYS.courseEnterUrl) || ''; const okUrl = !!url && (location.href.indexOf(url) !== -1 || url.indexOf(location.origin) !== -1); const age = Date.now() - ts; const settle = readCfg().pageSettleMs; return (ts > 0 && age < settle) && okUrl; } catch { return false; } } function getAllAnchors() { return Array.from(document.querySelectorAll('a[href]')); } function getByText(selector, texts) { const arr = Array.isArray(texts) ? texts : [texts]; const nodes = Array.from(document.querySelectorAll(selector)); return nodes.find(n => arr.some(t => (n.textContent || '').trim().includes(t))); } function inCompletionOverlay(el) { try { return !!(el && el.closest && el.closest('.anew')); } catch { return false; } } function getSectionNodes() { const container = document.querySelector('li.tabs-cont-item.active.tab-chapter') || document; let nodes = Array.from(container.querySelectorAll('dl.chapter-list-box')); if (!nodes.length) nodes = Array.from(document.querySelectorAll('dl.chapter-list-box')); return nodes.filter(n => { if (!n || inCompletionOverlay(n)) return false; const style = window.getComputedStyle(n); return !(style && style.display === 'none'); }); } function extractSectionInfo(node) { const titleEl = node.querySelector('.chapter-right .chapter-item .text-overflow'); const statusEl = node.querySelector("span[id*='finishStatus']") || node.querySelector('.section-item .item.pointer span'); const iconEl = node.querySelector('.chapter-left i.iconfont'); const title = titleEl ? (titleEl.getAttribute('title') || titleEl.textContent || '').trim() : (node.textContent||'').trim().slice(0,40); const statusText = (statusEl ? statusEl.textContent : '').trim(); const iconCls = iconEl ? iconEl.className : ''; // H5页面通常显示“学习中”或“开始学习”,如果不想被卡住,需要特殊的逻辑 const reviewed = /复习|已完成|完成|100%/.test(statusText) || /icon-reload-full/.test(iconCls); const clickable = (statusEl && statusEl.closest('.section-item') && statusEl.closest('.section-item').querySelector('.item.pointer')) || statusEl || node; return { node, title, statusText, reviewed, clickable }; } function canNavigate() { const now = Date.now(); const last = +localStorage.getItem(LS_KEYS.navLock) || 0; return (now - last) > 4000; } function setNavLock() { localStorage.setItem(LS_KEYS.navLock, String(Date.now())); } function safeClick(el) { if (!el) return false; el.dispatchEvent(new MouseEvent('click', { bubbles: true })); if (typeof el.click === 'function') el.click(); return true; } function courseFullyLearnedHeuristic() { // 简单判定:大纲全部复习 tryExpandOutline(); const nodes = getSectionNodes(); if (!nodes.length) return false; const infos = nodes.map(extractSectionInfo); const reviewed = infos.filter(i => i.reviewed).length; return (reviewed > 0 && reviewed === nodes.length); } function tryExpandOutline() { const labels = ['展开', '显示更多', '更多', '展开全部']; const toggles = Array.from(document.querySelectorAll('button,a,div,span')) .filter(el => labels.some(l => (el.textContent||'').trim().includes(l))); toggles.forEach(el => { try { el.click(); } catch(_) {} }); } // ---------------------------- // [核心] H5 内嵌/Iframe 处理 // ---------------------------- async function handleH5Content() { if (!readCfg().autoAnswer) return false; // 1. 寻找 iframe (你的截图显示内容在iframe里) const iframes = document.querySelectorAll('iframe'); let targetDoc = null; // 尝试访问 iframe 内容 for (const f of iframes) { try { if (f.contentDocument && (f.contentDocument.body.innerText.includes('提交') || f.contentDocument.querySelector('input'))) { targetDoc = f.contentDocument; break; } } catch (e) { /* 跨域无法访问 */ } } // 如果没有 iframe,也可能是直接渲染在 div 里,fallback 到 document if (!targetDoc) { // 检查页面是否有典型的答题特征 if (document.querySelector('.question-list') || document.body.innerText.includes('问题列表')) { targetDoc = document; } } if (!targetDoc) return false; // 2. 查找选项 (Radio/Checkbox) const inputs = Array.from(targetDoc.querySelectorAll('input[type="radio"], input[type="checkbox"], .option-item')); if (inputs.length === 0) return false; debugLog('检测到测验题目,尝试自动作答...'); // 策略:每个题选第一个,确保所有题都有选 // 简单的去重逻辑,避免重复点击 let clickedCount = 0; // 分组逻辑比较复杂,这里采用简化版:全选第一个,或者全部点一遍(如果是多选) // 为了防止把正确答案取消,这里只点击 "未选中" 的 for (const input of inputs) { // 如果是 checkbox/radio,检查 checked if (input.checked) continue; // 截图里是多选题(方框),我们可以尝试点击第一个选项 // 或者更暴力一点,把所有选项都点上?不,还是点第一个稳妥,能提交就行 // 这里做一个假设:每个题目是一组 DOM,我们只点每组的第一个 // 尝试找到父级容器来区分题目 // 简单策略:遍历所有input,直接点击。 // 但为了避免多选导致错误,我们尽量只点每组第一个。 // 由于结构未知,这里采用:每隔4个点一个,或者只点当前未选中的第一个。 // 改进策略:查找所有题目的容器 // 如果无法区分,就尝试点击所有可见的 input 且 name 不同的? // 既然不知道结构,采用“盲点第一个”: // 找到所有 input,直接点第一个即可触发“已做”状态 if (clickedCount === 0) { safeClick(input); clickedCount++; await sleep(200); } // 如果是多选题,可能需要多选。截图显示是多选。 // 让我们尝试把所有题的第一个选项都选上。 // 很难区分哪是题目的边界。 // 备用方案:把所有 unchecked 的 checkbox 都点一遍?不,那样也是全选。 } // 再次尝试:如果还没做完,通常需要点击“提交”。 const btnSubmit = Array.from(targetDoc.querySelectorAll('button, a, div')).find(el => { const t = (el.textContent || '').trim(); return t === '提交' || t === '提交答案' || t === 'Submit'; }); if (btnSubmit) { debugLog('找到提交按钮,点击提交'); safeClick(btnSubmit); await sleep(1000); return true; // 正在处理中 } return false; } // ---------------------------- // Navigation & Automation // ---------------------------- async function goNextLesson(force = false) { const infos = getSectionNodes().map(extractSectionInfo); // 默认找第一个未复习的。如果是 force=true,则找当前正在进行的下一个,或者直接找下一个 let target = infos.find(i => !i.reviewed); if (force) { // 强制模式:找到当前高亮的节点,然后取下一个 const currentIndex = infos.findIndex(i => i.node.classList.contains('active') || i.node.innerHTML.includes('color:#ff')); // 这里的 active 类名可能不准确,改用 statusText 判定 const runningIndex = infos.findIndex(i => i.statusText.includes('学习中') || i.statusText.includes('进行中')); if (runningIndex !== -1 && runningIndex + 1 < infos.length) { target = infos[runningIndex + 1]; } else if (infos.length > 0) { // 实在找不到,就找第一个没做的 target = infos.find(i => !i.reviewed); } } if (target && target.clickable) { debugLog(force ? '强制跳转下一节' : '进入下一节', `${target.title}`); return safeClick(target.clickable); } // 尝试通用按钮 const nextBtn = getByText('a,button,div', ['下一节', '下一章', '继续学习']); if (nextBtn && !inCompletionOverlay(nextBtn)) return safeClick(nextBtn); return false; } async function driveStudyPage() { if (pageJustEnteredCourse()) { setTimeout(tick, 1500); return; } // 优先处理 H5 await handleH5Content(); if (courseFullyLearnedHeuristic()) { debugLog('课程完成,切下一个'); completeCurrentAndDequeue(); await goNextCourseOrIndex(); return; } const v = document.querySelector('video'); if (v) { v.muted = !!readCfg().mute; ensurePlaying(v); if (!v.dataset.cttEndedBound) { v.dataset.cttEndedBound = '1'; v.addEventListener('ended', async () => { debugLog('视频结束,去下一节'); await sleep(1000); if (!(await goNextLesson())) { // 视频结束但点不到下一节(可能是H5卡住),这里不自动跳,等待主循环或人工 } }); } } else { // 没视频,可能是文档或H5。 // 如果是 H5 且无法自动处理,脚本会卡住。 // 用户可以点击面板上的【跳过当前】。 } // 通用翻页尝试 const contBtn = getByText('a,button,div', ['继续学习', '下一节']); if (contBtn && !inCompletionOverlay(contBtn) && !/复习/.test(contBtn.innerText)) { safeClick(contBtn); } } function ensurePlaying(video) { if (!video) return; const tryPlay = () => video.play().catch(() => {}); tryPlay(); const timer = setInterval(tryPlay, 3000); video.addEventListener('playing', () => clearInterval(timer), { once: true }); setTimeout(() => clearInterval(timer), 30000); } // ... (Course Collection Logic - unchanged) ... function isCompletedText(text) { return /已完成|已学完|100%/.test((text||'').replace(/\s+/g,'')); } function findCourseCard(el) { return el.closest('li, [class*="course"], [class*="card"]'); } function isCourseLink(href, txt) { return (/study\/course/.test(href) || /\/course\/detail/.test(href) || txt.includes('继续学习')); } function collectCourseLinks() { if (atSubjectDetail()) { const list = []; const items = document.querySelectorAll('.item[data-resource-id]'); items.forEach(it => { const rid = it.getAttribute('data-resource-id'); const st = it.getAttribute('data-section-type'); if(!rid || /完成/.test(it.innerText)) return; list.push({id:rid, url: `${location.origin}/#/study/course/detail/${st}&${rid}`, title: it.querySelector('[title]')?.getAttribute('title')||'未命名'}); }); return list; } // 列表页通用扫描 const wanted = []; const seen = new Set(); document.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href'); if(isCourseLink(href, a.innerText) && !isCompletedText(findCourseCard(a)?.innerText || a.innerText)) { const u = new URL(href, location.href).href; if(!seen.has(u)) { seen.add(u); wanted.push({url:u, title: a.title||a.innerText}); } } }); return wanted; } async function goCourse(uOrItem) { const item = normalizeQueueItem(uOrItem); if(item.url) markInProgress(item.url); stopStudyRefresh(); localStorage.setItem(LS_KEYS.courseEnterAt, String(Date.now())); localStorage.setItem(LS_KEYS.courseEnterUrl, item.url); location.assign(item.url); } async function goNextCourseOrIndex() { const next = normalizeQueueArray(getQueue()).find(it => it.status === 'in_progress') || normalizeQueueArray(getQueue()).find(it => it.status === 'pending'); if (next) { await sleep(2000); await goCourse(next); } else { await backToIndexAndNext(); } } async function backToIndexAndNext() { if(!canNavigate()) return; localStorage.setItem(LS_KEYS.resumeAt, String(Date.now() + readCfg().returnDelayMs)); setNavLock(); location.assign(location.origin + '/#/study/course/index'); } // Main Loop let tickBusy = false; async function tick() { if (!isRunning() || tickBusy) return; tickBusy = true; try { if (atAnyCourseListIndex()) { const resume = +localStorage.getItem(LS_KEYS.resumeAt)||0; if(resume && Date.now() < resume) { setTimeout(tick, 1000); return; } let q = getQueue(); if(!q.length) setQueue(collectCourseLinks()); const next = normalizeQueueArray(getQueue()).find(it => it.status === 'in_progress' || it.status === 'pending'); if(next) await goCourse(next); } else if (atStudyPage()) { startStudyRefresh(); await driveStudyPage(); } } catch (e) { console.warn(e); } finally { tickBusy = false; } setTimeout(tick, 2500); } onHashChange(() => { if (isRunning()) setTimeout(tick, 600); }); function init() { migrateCurrentUrlToId(); renderUI(); if (isRunning()) tick(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();