// ==UserScript==
// @name 学堂在线 | 心跳式重启:硬等10秒 + 全日志 + 连续页面生效
// @namespace https://www.xuetangx.com/
// @version 5.1.1
// @description 当作普通网页处理:每次URL变化都重启流程;每页先等10秒;/video/ 等完再下一页;/exercise/、/article/、/discussion/ 直接下一页;全步骤控制台日志(更稳健的“下一页”定位)
// @match https://www.xuetangx.com/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const TAG = '[XT-Auto]';
const START_DELAY_MS = 10_000; // 每页硬等 10 秒
const URL_HEARTBEAT_MS = 1000; // URL 心跳检测间隔
const TIME_SELECTOR = '#qa-video-wrap > div > xt-wrap > xt-controls > xt-inner > xt-time';
// —— 新:多选择器覆盖不同页面结构(含讨论页更深层级)——
const NEXT_SELECTORS = [
// 讨论页提供的路径(更深一层)
'#app > div > div.app-main_.appMain > div.courseActionLesson > div.lesson_rightcon > div > div > div > div.control > p.next',
// 旧结构(练习/视频常见)
'#app > div > div.app-main_.appMain > div.courseActionLesson > div.lesson_rightcon > div > div.control > p.next',
// 兜底范围更广的选择器
'div.control p.next',
'p.next',
'a.next',
'button.next',
'a[rel="next"]'
];
// 视频检测
const TIME_FIND_TIMEOUT_MS = 60_000;
const TIME_FIND_POLL_MS = 500;
const INTERVAL_MS = 600;
const TOLERANCE_S = 1;
const CONFIRM_TIMES = 2;
// “下一页”点击策略
const NEXT_MAX_TRIES = 5;
const NEXT_RETRY_GAP_MS = 1000;
// 需直接跳过的路径
const SKIP_PATH_RE = /(\/exercise\/|\/article\/|\/discussion\/)/i;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function parseTimeSec(txt) {
if (!txt) return NaN;
const a = txt.trim().split(':').map(Number);
if (a.some(isNaN)) return NaN;
if (a.length === 3) return a[0]*3600 + a[1]*60 + a[2];
if (a.length === 2) return a[0]*60 + a[1];
return Number(a[0]) || NaN;
}
function isVisible(el) {
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0;
}
function isDisabled(el) {
return el?.hasAttribute?.('disabled')
|| el?.getAttribute?.('aria-disabled') === 'true'
|| /\b(disable|disabled)\b/i.test(el?.className || '');
}
// —— 新:更稳健地查找“下一页”按钮/链接 ——
function findNextElement() {
// 1) 逐个候选选择器查找
for (const sel of NEXT_SELECTORS) {
const el = document.querySelector(sel);
if (el) {
// 若 p.next 里面包了 <a>,优先返回可点击的 <a>
const a = el.querySelector?.('a[href]') || el;
if (isVisible(a) && !isDisabled(a)) return a;
}
}
// 2) 兜底:全局搜含 next 的 class 或带“下一页”文案的可点元素
const candidates = Array.from(document.querySelectorAll('a,button,p,div,span'))
.filter(n => isVisible(n) && !isDisabled(n));
const byClass = candidates.find(n => /\bnext\b/i.test(n.className || ''));
if (byClass) return byClass.querySelector?.('a[href]') || byClass;
const byText = candidates.find(n => (n.textContent || '').trim().includes('下一页'));
if (byText) return byText.querySelector?.('a[href]') || byText;
// 3) 再兜底:寻找拥有 rel="next" 的链接
const relNext = document.querySelector('a[rel="next"]');
if (relNext && isVisible(relNext) && !isDisabled(relNext)) return relNext;
return null;
}
async function tryClickNextMulti() {
for (let i = 1; i <= NEXT_MAX_TRIES; i++) {
const el = findNextElement();
console.log(TAG, `定位“下一页”(${i}/${NEXT_MAX_TRIES}):`, el);
if (!el) { await sleep(NEXT_RETRY_GAP_MS); continue; }
// 可点击链接优先
const link = el.tagName === 'A' ? el : el.querySelector?.('a[href]');
if (link && isVisible(link) && !isDisabled(link)) {
console.log(TAG, '点击 <a>:', link.href);
link.scrollIntoView({ block: 'center', inline: 'center' });
link.click();
return true;
}
// 否则对元素本体点击 + MouseEvent
if (isVisible(el) && !isDisabled(el)) {
console.log(TAG, '对元素执行 .click() + MouseEvent');
el.scrollIntoView({ block: 'center', inline: 'center' });
try { el.click(); } catch (e) { console.log(TAG, 'click() 异常:', e); }
const ok = el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
console.log(TAG, 'MouseEvent dispatch 结果 =', ok);
// 若元素带 href(如 <p href> 的极端情况),也尝试跳转
const href = el.getAttribute?.('href');
if (href) { console.log(TAG, '元素带 href,location.assign:', href); location.assign(href); }
return true;
}
console.log(TAG, '暂不可点,等待后重试…');
await sleep(NEXT_RETRY_GAP_MS);
}
console.log(TAG, '多次尝试后仍未能点击“下一页”,放弃。');
return false;
}
async function findTimeWrapLimited() {
const start = Date.now();
while (Date.now() - start < TIME_FIND_TIMEOUT_MS) {
const el = document.querySelector(TIME_SELECTOR);
if (el) { console.log(TAG, '找到时间条元素:', el); return el; }
console.log(TAG, '未找到时间条,', TIME_FIND_POLL_MS, 'ms 后重试…');
await sleep(TIME_FIND_POLL_MS);
}
return null;
}
// —— 单次页面流程 ——
let videoPollTimer = null;
async function runOnceForCurrentPage() {
const href = location.href;
const onVideo = /\/video\//i.test(location.pathname);
const onSkip = SKIP_PATH_RE.test(location.pathname); // exercise/article/discussion
console.log(TAG, '开始处理页面:', href);
console.log(TAG, '硬性等待', START_DELAY_MS, 'ms…');
await sleep(START_DELAY_MS);
if (onSkip) {
console.log(TAG, '识别为可跳过页面(/exercise/ | /article/ | /discussion/)→ 点击“下一页”。');
await tryClickNextMulti();
return;
}
if (onVideo) {
console.log(TAG, '识别为视频页:/video/ → 查找时间条并等待播放完。');
const timeWrap = await findTimeWrapLimited();
if (!timeWrap) { console.log(TAG, '超时仍未找到时间条。谨慎起见不跳转。'); return; }
let hits = 0;
clearInterval(videoPollTimer);
videoPollTimer = setInterval(async () => {
const spans = timeWrap.querySelectorAll('span');
if (spans.length < 2) { console.log(TAG, '时间条结构异常(span < 2),跳过本次。'); return; }
const curTxt = (spans[0].textContent || '').trim();
const totTxt = (spans[1].textContent || '').trim();
const cur = parseTimeSec(curTxt);
const tot = parseTimeSec(totTxt);
console.log(TAG, `轮询:当前=${curTxt}(${cur}s) / 总=${totTxt}(${tot}s)`);
if (!isFinite(cur) || !isFinite(tot) || tot <= 0) { console.log(TAG, '时间解析异常,跳过。'); return; }
const reached = cur >= Math.max(0, tot - TOLERANCE_S);
console.log(TAG, '是否达成结束条件(含容差)=', reached, ' 连续命中=', reached ? (hits + 1) : 0, '/', CONFIRM_TIMES);
if (reached) {
hits++;
if (hits >= CONFIRM_TIMES) {
console.log(TAG, '播放结束,点击“下一页”。');
clearInterval(videoPollTimer);
await tryClickNextMulti();
}
} else {
hits = 0;
}
}, INTERVAL_MS);
return;
}
console.log(TAG, '既非 /video/ 也非 /exercise/ /article/ /discussion/,本页不操作。');
}
// —— 心跳监控 ——
let lastUrl = location.href;
let running = false;
function resetState() {
console.log(TAG, '重置状态:清理轮询定时器。');
clearInterval(videoPollTimer);
videoPollTimer = null;
running = false;
}
async function ensureRunner() {
if (running) return;
running = true;
try {
await runOnceForCurrentPage();
} catch (e) {
console.log(TAG, '运行异常:', e);
} finally {
console.log(TAG, '本轮处理结束,等待 URL 变化触发下一轮。');
}
}
console.log(TAG, '脚本启动。初始 URL =', lastUrl);
ensureRunner();
setInterval(() => {
const now = location.href;
if (now !== lastUrl) {
console.log(TAG, '检测到 URL 变化:', lastUrl, '→', now);
lastUrl = now;
resetState();
ensureRunner();
}
}, URL_HEARTBEAT_MS);
window.addEventListener('beforeunload', resetState);
})();