// ==UserScript== // @name 人教培训(px.pep.com.cn) 回放课程收集与连播 // @namespace tabbit/pep-px-replay // @version 3.3.0 // @description 按报名信息中的学段/学科遍历课程,列出全部可"观看回放"的课程,一键选择连播(含 gensee 播放页自动续播;跨域名/iframe/中间页/取链错误自适应;Chrome 自动播放策略与跨域 iframe 专项加固) // @author ruziniujs // @match *://*/* // 说明:这里故意用全站匹配。原因是不同学科的"观看回放"可能跳到展视互动 // (gensee.com / gensee.cn / gensee.com.cn / csslcloud.net) 或其他机构自有域名, // 枚举不完。脚本内部有严格闸门:只有处于"连播进行中"状态才会去接管页面, // 平时在无关网站上完全不显示面板、不注册任何监听。 // @run-at document-idle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @noframes // ==/UserScript== (function () { 'use strict'; const VERSION = '3.3.0'; const NS = 'pep_px_replay_v2'; // 站点已确认的默认值(页面下拉框缺失时兜底) const DEFAULT_STAGES = [{ v: '3', t: '小学' }, { v: '4', t: '初中' }, { v: '5', t: '高中' }]; const DEFAULT_SIDS = [ { v: '18', t: '数学' }, { v: '6', t: '英语' }, { v: '13', t: '音乐' }, { v: '11', t: '美术' }, { v: '7', t: '物理' }, { v: '8', t: '化学' }, { v: '9', t: '生物学' }, { v: '10', t: '地理' }, { v: '34', t: '日语' }, { v: '35', t: '俄语' }, { v: '12', t: '体育与健康' }, { v: '93', t: '信息科技' }, { v: '129', t: '特殊教育' } ]; const CFG = { panelId: 'pep-px-replay-panel', concurrency: 5, videoWaitMs: 30000, stallMs: 120000, // 播放页迟迟找不到 video 时的提示阈值 hintMs: 20000, // 落在列表页/错误页且找不到 video 时,多久后自动跳到下一门 wrongPageSkipMs: 25000, // 中间页自动点击最多尝试次数(同一页面) maxHop: 3, // 轮询间隔(Chrome 后台标签页会被节流,配合事件唤醒使用) tickMs: 2500, // 发现播放器被嵌在 iframe 里,等待多久后把它提升为顶层页面 iframePromoteMs: 6000, // 硬性看门狗:任何页面停留超过该秒数仍无有效播放,强制跳到下一门(Chrome 兜底) hardStuckSec: 100, // 找到 video 但一直无法开始播放,超过该秒数视为失败并计入看门狗 playStartTimeoutSec: 60 }; // 展视互动(Gensee)及其播放器常用域名,任一命中都视为"播放页" const GENSEE_RE = /(^|\.)(gensee\.com|gensee\.cn|gensee\.com\.cn|gensee\.net|csslcloud\.net|263\.net)$/i; /* --------------------------------------------------------------- 存取 */ const store = { cache: Object.create(null), get(k, d) { const id = NS + ':' + k; if (id in this.cache) return this.cache[id]; try { if (typeof GM_getValue === 'function') { const v = GM_getValue(id); if (v !== undefined && v !== null) return v; } } catch (e) {} try { const raw = localStorage.getItem(id); return raw ? JSON.parse(raw) : d; } catch (e) { return d; } }, // 普通写入:立即返回,用于界面状态等不跨页面的数据 set(k, v) { const id = NS + ':' + k; this.cache[id] = v; try { if (typeof GM_setValue === 'function') { GM_setValue(id, v); return; } } catch (e) {} try { localStorage.setItem(id, JSON.stringify(v)); } catch (e) {} }, /* * 关键写入:Chrome 版 Tampermonkey(MV3) 的 GM_setValue 是异步投递到后台的, * 写完立刻 location.href 跳转,新页面很可能读不到 —— 这正是"Chrome 里只播 * 第一门、之后接不上"的根因。这里等到真正落盘(并回读校验)再跳转。 */ async setSync(k, v) { const id = NS + ':' + k; this.cache[id] = v; const ok = (x) => x && typeof x === 'object' && x.active === v.active && x.index === v.index && Array.isArray(x.queue) && v.queue && x.queue.length === v.queue.length; try { if (typeof GM_setValue === 'function') await Promise.resolve(GM_setValue(id, v)); } catch (e) {} try { localStorage.setItem(id, JSON.stringify(v)); } catch (e) {} for (let i = 0; i < 20; i++) { let got = null; try { if (typeof GM_getValue === 'function') got = GM_getValue(id); } catch (e) {} if (!ok(got)) { try { const raw = localStorage.getItem(id); if (raw) got = JSON.parse(raw); } catch (e) {} } if (ok(got)) return true; // 还没落地就再写一次(部分版本首写会被丢弃) try { if (typeof GM_setValue === 'function') GM_setValue(id, v); } catch (e) {} await sleep(100); } return false; } }; // 兼容旧调用名的同步写 const setPlaySync = (s) => store.setSync('play', s); /* --------------------------------------------------------------- 工具 */ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const clean = (s) => (s || '').replace(/[\s\u00a0\u3000]+/g, ' ').trim(); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const isPepListPage = () => /(^|\.)pep\.com\.cn$/i.test(location.hostname) && /\/px\/index\//i.test(location.href); const isPepPage = () => /(^|\.)pep\.com\.cn$/i.test(location.hostname); const isGenseePage = () => GENSEE_RE.test(location.hostname); /* ------------------------------------------------------- 活动ID(多站点适配) */ // 支持 /px/index/187、/px/entryRoom/187/15330/2、/user/showInfo/187 等形态 function getActId() { const m = location.href.match(/\/px(?:com)?\/[a-zA-Z]+\/(\d+)/i) || location.href.match(/\/user\/[a-zA-Z]+\/(\d+)/i); if (m && m[1]) { store.set('lastAct', m[1]); return m[1]; } return store.get('lastAct', '') || ''; } const regUrl = () => `/web/index.php?/user/showInfo/${getActId()}`; const listUrl = (sid, stage) => `/web/index.php?/px/index/${getActId()}/${sid}/${stage}`; // 按活动ID隔离课程列表与报名信息 const itemsKey = () => 'items:' + getActId(); const regKey = () => 'reg:' + getActId(); const getItems = () => store.get(itemsKey(), []); const setItems = (arr) => store.set(itemsKey(), arr); /* =========================================================== 列表页解析 */ function readStages() { const sel = document.getElementById('stage'); if (sel && sel.options && sel.options.length) { return Array.from(sel.options).map(o => ({ v: o.value, t: clean(o.text) })).filter(x => x.v !== ''); } return DEFAULT_STAGES.slice(); } function readSids() { const sel = document.getElementById('sid'); if (sel && sel.options && sel.options.length) { return Array.from(sel.options).map(o => ({ v: o.value, t: clean(o.text) })).filter(x => x.v !== ''); } return DEFAULT_SIDS.slice(); } // 站点标记可观看回放的按钮图片 const REPLAY_IMG_RE = /btn_gkhf|gkhf|replay/i; const imgSrcOf = (el) => Array.from((el || document).querySelectorAll('img')).map(i => i.getAttribute('src') || ''); /* * 【修复 4】取链更精准: * 原来是对整格取第一个 ,如果格子里先有"课程介绍""文件下载"等链接, * 就会把非播放链接存进队列,跳过去之后自然接不上。 * 现在优先取"自身携带回放按钮图标"的那个链接,其次取文本含回放/观看的链接, * 最后才退回第一个链接。 */ function pickReplayAnchor(cell) { const links = Array.from(cell.querySelectorAll('a[href]')); if (!links.length) return null; const byImg = links.find(a => imgSrcOf(a).some(s => REPLAY_IMG_RE.test(s))); if (byImg) return byImg; const byText = links.find(a => /回放|观看|进入|播放/.test(clean(a.textContent))); if (byText) return byText; const byTitle = links.find(a => /回放|观看|play|replay|entry/i.test(a.getAttribute('title') || a.getAttribute('href') || '')); if (byTitle) return byTitle; return links[0]; } function parseCourses(html, pageUrl) { const doc = new DOMParser().parseFromString(html, 'text/html'); const rows = Array.from(doc.querySelectorAll('.con_table_pxkc_gztb2020b table tr')); const out = []; let lastName = ''; for (const tr of rows) { const cell = tr.querySelector('td.txt_pxrk'); if (!cell) continue; const h4 = tr.querySelector('td.txt_pxkc_xk h4'); if (h4 && clean(h4.textContent)) lastName = clean(h4.textContent); const a = pickReplayAnchor(cell); const imgs = imgSrcOf(cell); // 有回放图标,或链接本身指向回放入口,才认为可观看 const hasReplayImg = imgs.some(s => REPLAY_IMG_RE.test(s)); const hasReplayLink = !!a && /回放|观看|进入|播放|play|replay|entry/i.test( (a.getAttribute('href') || '') + ' ' + (a.getAttribute('title') || '') + ' ' + clean(a.textContent) ); if (!hasReplayImg && !hasReplayLink) continue; if (!a) continue; const title = clean((tr.querySelector('td.txt_pxkc_pxxx h6') || {}).textContent); if (!title) continue; const time = clean((tr.querySelector('.showtime_lesson') || {}).textContent); const spans = Array.from(tr.querySelectorAll('td.txt_pxkc_pxxx p span')) .map(s => clean(s.textContent)).filter(Boolean); let href = a.getAttribute('href') || ''; try { href = new URL(href, pageUrl).href; } catch (e) {} out.push({ subjectLabel: lastName, title, time, extra: spans.join(' | '), entryUrl: href }); } return out; } /* --------------------------------------------------- 报名信息(学段/学科) */ // 从报名信息页面 HTML 解析已选学段×学科组合 function parseRegistrationPairs(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const selects = Array.from(doc.querySelectorAll('select')); const pairs = []; let curStage = null; for (const s of selects) { const name = (s.getAttribute('name') || '').toLowerCase(); const v = s.value; const opt = s.selectedIndex >= 0 ? s.options[s.selectedIndex] : null; const text = opt ? clean(opt.text) : ''; if (name.indexOf('stage') === 0) { curStage = (v && v !== '0' && text) ? { v, t: text } : null; } else if (name.indexOf('sid') === 0) { const sid = (v && v !== '0' && text) ? { v, t: text } : null; if (curStage && sid) pairs.push({ stage: curStage, sid }); curStage = null; } } const seen = new Set(); const out = []; for (const p of pairs) { const k = p.stage.v + '|' + p.sid.v; if (seen.has(k)) continue; seen.add(k); out.push(p); } return out; } async function fetchRegistrationPairs() { // 当前就在报名信息页时,直接解析当前文档,避免额外请求 if (/\/user\/showInfo\//i.test(location.href)) { const p = parseRegistrationPairs(document.documentElement.outerHTML); if (p.length) return { pairs: p, source: 'current' }; } try { const r = await fetch(location.origin + regUrl(), { credentials: 'include' }); const html = await r.text(); if (/请您先登录/.test(html)) return { pairs: [], source: 'login' }; return { pairs: parseRegistrationPairs(html), source: 'showInfo' }; } catch (e) { return { pairs: [], source: 'error' }; } } /* ============================================================ 扫描主流程 */ let scanning = false; async function scanAll() { if (scanning) return { ok: false, error: '扫描进行中' }; scanning = true; try { const origin = location.origin; // 以报名信息为准:只遍历已报名的学段×学科 setStatus('正在读取报名信息(学段/学科)…'); const reg = await fetchRegistrationPairs(); const onlyReg = ui && ui.els.onlyReg ? ui.els.onlyReg.checked : true; let regLabel = ''; if (reg.pairs.length) { regLabel = reg.pairs.map(p => p.stage.t + p.sid.t).join('、'); store.set(regKey(), { pairs: reg.pairs, source: reg.source, at: Date.now() }); } const tasks = []; if (reg.pairs.length && onlyReg) { for (const p of reg.pairs) tasks.push({ st: p.stage, sd: p.sid }); } else { // 无报名信息或用户关闭了"仅报名"时,回退为全站遍历 const stages = readStages(); const sids = readSids(); for (const st of stages) for (const sd of sids) tasks.push({ st, sd }); } if (reg.pairs.length && onlyReg) { setStatus(`报名学段学科:${regLabel}(共 ${tasks.length} 个组合),开始遍历…`); } else if (reg.pairs.length) { setStatus(`未勾选"仅报名",全站遍历 ${tasks.length} 个组合…`); } else { setStatus(`未读取到报名信息,回退全站遍历 ${tasks.length} 个组合…`); } const map = new Map(); const seenDup = new Set(); let done = 0, idx = 0, loginBlocked = false; const worker = async () => { while (idx < tasks.length) { const t = tasks[idx++]; const url = origin + listUrl(t.sd.v, t.st.v); try { const r = await fetch(url, { credentials: 'include' }); const html = await r.text(); if (/请您先登录/.test(html)) { loginBlocked = true; } else { for (const c of parseCourses(html, url)) { const key = c.entryUrl || (c.subjectLabel + '|' + c.title); // 同名同时间的记录(不同学段下重复挂载)视为同一门课,避免重复播放 const dupKey = clean(c.title) + '@' + clean(c.time); if (map.has(key) || seenDup.has(dupKey)) continue; seenDup.add(dupKey); if (!map.has(key)) { map.set(key, { id: key, title: c.title, entryUrl: c.entryUrl, stageOpt: t.st.t, sidOpt: t.sd.t, subjectLabel: c.subjectLabel || (t.st.t + t.sd.t), time: c.time, extra: c.extra, disabled: false }); } } } } catch (e) { // 单个组合失败不影响整体 } done++; if (done % 5 === 0 || done === tasks.length) { setStatus(`扫描中 ${done}/${tasks.length} —— 已发现 ${map.size} 门可回放课程`); renderList(Array.from(map.values())); } } }; await Promise.all(Array.from({ length: Math.min(CFG.concurrency, tasks.length) }, worker)); const items = Array.from(map.values()); setItems(items); renderList(items); if (loginBlocked) { setStatus(`扫描完成但存在登录限制:共 ${items.length} 门。若数量偏少请确认已登录`); } else { setStatus(`扫描完成:${tasks.length} 个页面中找到 ${items.length} 门可回放课程`); } return { ok: true, count: items.length }; } finally { scanning = false; } } /* ================================================================ 面板 UI */ let ui = null; function ensurePanel() { if (ui && ui.host && ui.host.isConnected) return ui; const old = document.getElementById(CFG.panelId); if (old) old.remove(); const host = document.createElement('div'); host.id = CFG.panelId; host.style.cssText = 'position:fixed;z-index:2147483600;top:70px;right:12px;width:390px;'; const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `
人教培训 · 回放收集/连播v${VERSION}
就绪

          
已选 0 / 共 0 门
点击「扫描报名学段/学科」
`; (document.body || document.documentElement).appendChild(host); const q = (s) => root.querySelector(s); ui = { host, els: { status: q('[data-el=status]'), now: q('[data-el=now]'), count: q('[data-el=count]'), list: q('[data-el=list]'), mute: q('[data-el=mute]'), rate: q('[data-el=rate]'), body: q('.bd'), selcount: q('[data-el=selcount]'), playBtn: q('[data-act=play]'), onlyReg: q('[data-el=onlyReg]'), regInfo: q('[data-el=reginfo]'), dbg: q('[data-el=dbg]'), warn: q('[data-el=warn]'), log: q('[data-el=log]') } }; root.addEventListener('click', (ev) => { const el = ev.target.closest('[data-act]'); if (!el) return; const act = el.getAttribute('data-act'); if (act === 'fold') { ui.els.body.classList.toggle('hide'); el.textContent = ui.els.body.classList.contains('hide') ? '▸' : '▾'; } else if (act === 'close') { host.style.display = 'none'; setTimeout(() => { host.style.display = ''; }, 400); } else if (act === 'scan') { if (isPepListPage()) scanAll(); else setStatus('请在课程学习列表页(/px/index/)使用扫描'); } else if (act === 'play') { startPlayback(); } else if (act === 'skip') { advance('manual-skip'); } else if (act === 'all') { setAllSelected(true); } else if (act === 'none') { setAllSelected(false); } else if (act === 'invert') { invertSelected(); } else if (act === 'stop') { stopPlayback(); } else if (act === 'copy') { const items = getItems(); const txt = items.map((it, i) => `${i + 1}. [${it.stageOpt}${it.sidOpt}] ${it.title}${it.time ? ' (' + it.time + ')' : ''}\n${it.entryUrl}`).join('\n'); try { navigator.clipboard.writeText(txt); setStatus('已复制 ' + items.length + ' 条到剪贴板'); } catch (e) { setStatus('复制失败,请手动选择列表'); } } else if (act === 'diag') { copyDiagnostics(); } }); const hd = root.querySelector('.hd'); let drag = null; hd.addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; const r = host.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!drag) return; host.style.left = Math.max(0, e.clientX - drag.dx) + 'px'; host.style.top = Math.max(0, e.clientY - drag.dy) + 'px'; host.style.right = 'auto'; }); window.addEventListener('mouseup', () => { drag = null; }); return ui; } function setStatus(msg) { if (ui && ui.els.status) ui.els.status.textContent = msg; } function setNow(msg) { if (ui && ui.els.now) ui.els.now.textContent = msg || ''; } function setWarn(msg) { if (ui && ui.els.warn) ui.els.warn.textContent = msg || ''; } /* --------------------------------------------------------- 自诊断日志 */ // Chrome 与 Edge 行为不一致时,靠这段日志定位"卡在哪一步" const diagLog = []; function setLog(msg) { if (!msg) return; const t = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const line = `${t} ${msg}`; diagLog.push(line); if (diagLog.length > 60) diagLog.shift(); if (ui && ui.els.log) { ui.els.log.textContent = diagLog.slice(-6).join('\n'); } try { console.log('[PEP连播]', line); } catch (e) {} } // 生成可复制的诊断快照,用户贴回来即可定位问题 function buildDiagnostics() { const st = getPlay() || {}; const cur = st.queue && st.queue[st.index] ? st.queue[st.index] : {}; let vids = []; try { collectVideos(document, vids, 0); } catch (e) {} const v = pickVideo(); const frames = Array.from(document.querySelectorAll('iframe,frame')).map(f => { let cross = false; try { void f.contentDocument; } catch (e) { cross = true; } const r = f.getBoundingClientRect(); return `${cross ? '跨域' : '同源'} ${Math.round(r.width)}x${Math.round(r.height)} ${(f.getAttribute('src') || '').slice(0, 90)}`; }); const lines = [ `脚本版本:${VERSION}`, `时间:${new Date().toISOString()}`, `当前地址:${location.href}`, `域名:${location.hostname} | 页面类型:${kindOfPage()}`, `连播状态:active=${!!st.active} index=${st.index}/${st.queue ? st.queue.length : 0}`, `当前课程:${cur.title || '(无)'}`, `课程链接:${cur.entryUrl || '(无)'}`, `video 元素数:${vids.length} | 可播放候选:${v ? '有' : '无'}`, v ? `播放器状态:paused=${v.paused} ended=${v.ended} muted=${v.muted} readyState=${v.readyState} currentTime=${Math.round(v.currentTime)} duration=${isFinite(v.duration) ? Math.round(v.duration) : 'NaN'} rate=${v.playbackRate} src=${(v.currentSrc || v.src || '').slice(0, 90)}` : '播放器状态:(无)', `停留时长:${Math.round((Date.now() - (playCtx.since || Date.now())) / 1000)}s | 轮询次数:${playCtx.tries || 0}`, `iframe(${frames.length}):`, ...frames.slice(0, 6).map(f => ' - ' + f), `cookieEnabled=${navigator.cookieEnabled}`, '', '—— 日志 ——', ...diagLog.slice(-25) ]; return lines.join('\n'); } async function copyDiagnostics() { const text = buildDiagnostics(); try { await navigator.clipboard.writeText(text); setStatus('诊断信息已复制,可直接粘贴反馈'); } catch (e) { // 剪贴板被拒时退回旧接口 try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); setStatus('诊断信息已复制(兼容模式)'); } catch (e2) { setStatus('复制失败,请手动查看面板日志'); } } setLog('已生成诊断快照'); return text; } // 调试行:显示当前页面类型与域名,方便判断"第二学科是否没被接管" function setDbg(msg) { if (!ui || !ui.els.dbg) return; const k = kindOfPage(); ui.els.dbg.textContent = `页面类型:${k} | 域名:${location.hostname}` + (msg ? ' | ' + msg : ''); } function groupKeyOf(it) { return it.subjectLabel || ([it.stageOpt, it.sidOpt].filter(Boolean).join(' ') || '未分组'); } function getSelCount() { return getItems().filter(i => i.entryUrl && !i.disabled).length; } function updateSelCount() { if (!ui) return; const all = getItems(); const sel = all.filter(i => i.entryUrl && !i.disabled).length; if (ui.els.selcount) ui.els.selcount.textContent = sel; if (ui.els.count) ui.els.count.textContent = all.length; if (ui.els.playBtn) { ui.els.playBtn.disabled = sel === 0; ui.els.playBtn.textContent = sel ? `播放选中 (${sel})` : '播放选中'; } } function syncCheckboxes() { if (!ui || !ui.els.list) return; const all = getItems(); ui.els.list.querySelectorAll('input[type=checkbox][data-id]').forEach(cb => { const it = all.find(x => x.id === cb.getAttribute('data-id')); if (it) cb.checked = !it.disabled; }); // 分组复选框的选中/半选状态 ui.els.list.querySelectorAll('input[type=checkbox][data-grp]').forEach(gcb => { const g = gcb.getAttribute('data-grp'); const arr = all.filter(i => i.entryUrl && groupKeyOf(i) === g); const on = arr.filter(i => !i.disabled).length; gcb.checked = arr.length > 0 && on === arr.length; gcb.indeterminate = on > 0 && on < arr.length; }); updateSelCount(); } function applySelection(fn) { const all = getItems(); fn(all); setItems(all); syncCheckboxes(); } function setAllSelected(on) { applySelection(all => { all.forEach(it => { if (it.entryUrl) it.disabled = !on; }); }); setStatus(on ? `已全选 ${getSelCount()} 门` : '已全部取消选择'); } function invertSelected() { applySelection(all => { all.forEach(it => { if (it.entryUrl) it.disabled = !it.disabled; }); }); setStatus(`已反选,当前选中 ${getSelCount()} 门`); } function toggleGroup(g, on) { applySelection(all => { all.forEach(it => { if (it.entryUrl && groupKeyOf(it) === g) it.disabled = !on; }); }); setStatus(`分组「${g}」${on ? '全选' : '全不选'}`); } function renderList(items) { if (!ui) return; ui.els.count.textContent = items.length; const box = ui.els.list; if (!items.length) { box.innerHTML = '
暂无条目
'; updateSelCount(); return; } const st = store.get('play', null); const curId = st && st.active && st.queue && st.queue[st.index] ? st.queue[st.index].id : ''; const groups = new Map(); for (const it of items) { const g = groupKeyOf(it); if (!groups.has(g)) groups.set(g, []); groups.get(g).push(it); } let html = ''; for (const [g, arr] of groups) { const playable = arr.filter(i => i.entryUrl); const on = playable.filter(i => !i.disabled).length; const allOn = playable.length > 0 && on === playable.length; html += `
${esc(g)} · ${arr.length} 门
`; for (const it of arr) { html += `
${esc(it.title)} ${esc(it.time || '')}
`; } } box.innerHTML = html; // 单条勾选 box.querySelectorAll('input[type=checkbox][data-id]').forEach(cb => { cb.addEventListener('change', () => { applySelection(all => { const t = all.find(x => x.id === cb.getAttribute('data-id')); if (t) t.disabled = !cb.checked; }); }); }); // 点击标题也能切换勾选 box.querySelectorAll('.row .t').forEach(sp => { sp.addEventListener('click', () => { const cb = sp.parentElement.querySelector('input[type=checkbox][data-id]'); if (!cb) return; cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); }); // 分组复选(半选状态用 indeterminate 表示) box.querySelectorAll('input[type=checkbox][data-grp]').forEach(gcb => { const g = gcb.getAttribute('data-grp'); const all = getItems(); const arr = all.filter(i => i.entryUrl && groupKeyOf(i) === g); const on = arr.filter(i => !i.disabled).length; gcb.indeterminate = on > 0 && on < arr.length; gcb.addEventListener('change', () => toggleGroup(g, gcb.checked)); }); // 行内「从此处播放」 box.querySelectorAll('[data-play]').forEach(btn => { btn.addEventListener('click', (ev) => { ev.stopPropagation(); startPlayback(btn.getAttribute('data-play')); }); }); updateSelCount(); } /* ========================================================== 连播控制 */ const getPlay = () => store.get('play', null); const setPlay = (s) => store.set('play', s); /* * 跳转前必须先让连播状态真正落盘,否则新页面读不到队列, * 就会表现为"第一门播完/跳走后不再续播"(Chrome 下最常见)。 */ async function gotoWithState(state, url, why) { setPlay(state); const ok = await setPlaySync(state); if (!ok) { setWarn('连播状态写入较慢,已尽力保存;若下一页未自动播放,请点「播放选中」继续。'); } await sleep(120); location.href = url; } async function startPlayback(startId) { const all = getItems(); let queue = all.filter(i => i.entryUrl && !i.disabled); // 行内「从此处播放」:即便该条未被勾选,也从它开始播,且后续只播已勾选课程 if (startId) { const target = all.find(i => i.id === startId && i.entryUrl); if (!target) { setStatus('该课程无可播放链接'); return; } const before = queue.filter(i => i.id !== startId); queue = [target].concat(before); } if (!queue.length) { setStatus('没有可播放课程:请先扫描,并勾选要播放的条目'); return; } const muted = ui && ui.els.mute ? ui.els.mute.checked : true; const rate = ui && ui.els.rate ? Number(ui.els.rate.value) || 1 : 1; const state = { active: true, queue, index: 0, muted, rate, startedAt: Date.now(), log: [] }; const first = queue[0]; setStatus(`开始播放:1/${queue.length}`); setNow(first.title); await gotoWithState(state, first.entryUrl, 'start'); } function stopPlayback() { const st = getPlay(); if (st) { st.active = false; st.stoppedAt = Date.now(); setPlay(st); } const v = pickVideo(); if (v) { try { v.pause(); } catch (e) {} } setStatus('已停止连播'); setNow(''); setWarn(''); } let advancing = false; async function advance(reason) { if (advancing) return; advancing = true; try { const st = getPlay(); if (!st || !st.active) return; const cur = st.queue[st.index] || {}; st.log = (st.log || []).concat([{ at: new Date().toISOString(), title: cur.title, reason }]); st.index = st.index + 1; if (st.index >= st.queue.length) { st.active = false; st.finishedAt = Date.now(); setPlay(st); setStatus(`全部 ${st.queue.length} 门已播放完毕`); setNow(''); setWarn(''); return; } const next = st.queue[st.index]; setStatus(`切换中:${st.index + 1}/${st.queue.length}`); setNow(next.title); playCtx = newCtx(); await gotoWithState(st, next.entryUrl, reason); } finally { setTimeout(() => { advancing = false; }, 1500); } } /* ---------------- 深度查找 video:自身文档 + 同源 iframe + shadow DOM ---------------- */ function collectVideos(root, out, depth) { if (!root || depth > 3) return; try { if (root.querySelectorAll) { for (const v of Array.from(root.querySelectorAll('video'))) out.push(v); } if (root.querySelectorAll) { for (const el of Array.from(root.querySelectorAll('*'))) { if (el.shadowRoot) collectVideos(el.shadowRoot, out, depth + 1); } } if (root.querySelectorAll) { for (const f of Array.from(root.querySelectorAll('iframe,frame'))) { try { const d = f.contentDocument; if (d) collectVideos(d, out, depth + 1); } catch (e) { /* 跨域 iframe:拿不到,属于已知限制 */ } } } } catch (e) {} } /* * 【修复 2】原版只查顶层 document 的