fix: resume video playback and advance linked exams with attempt controls

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+106
View File
@@ -0,0 +1,106 @@
/* Visible attempt labels shared by course quizzes and course exams. */
(function (root) {
'use strict';
const IGNORE = [
'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', 'option', 'pre', 'code', 'svg', 'canvas',
'.examtest_content_question', '.examtest_content_list', '.examtest_content_list_des',
'.pc_examtest_content_question', '.pc_examtest_content_list',
'.model_answer_wrapper', '.model_answer', '.model_dec',
'[data-question-stem]', '[data-question-text]', '[data-question-options]', '[data-answer-text]',
'.question-stem', '.question-text', '.question-options', '.question-answer', '.option-text', '.answer-text', '[role="option"]'
].join(',');
const BLOCK = /^(?:ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIELDSET|FIGCAPTION|FIGURE|FOOTER|FORM|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|SECTION|TABLE|TBODY|TD|TFOOT|TH|THEAD|TR|UL)$/;
const DIGITS = '[0-9零〇一二三四五六七八九十百两]+';
const TARGET = '(?:答题|作答|考试)';
const CAPTION = '(?:允许|允许的|可用|可|总|总共|最多|最大|剩余|还剩|还有|还可)';
const SEPARATOR = '(?:[::=]|为|是)?';
const LIMIT = '(?:仅|只有|仅有|只限)?';
const UNIT = '(?:次|[((]次[))])?';
const forms = [
new RegExp('^(?:本次|本场)?' + CAPTION + '(?:可|允许)?' + TARGET + '(?:总|剩余)?(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?' + TARGET + '(?:允许|可用|总|最多|最大|剩余|还剩)(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?' + TARGET + '(?:次数|机会)' + SEPARATOR + '(?:上限|最多|最大|仅剩|还剩|剩余)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|可|能|允许)?' + TARGET + '(' + DIGITS + ')次$'),
new RegExp('^(?:本次|本场)?(?:最多|总共|总|允许|还可|剩余)(?:可)?' + TARGET + '(' + DIGITS + ')次$'),
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|剩)?(' + DIGITS + ')次' + TARGET + '机会$'),
new RegExp('^(?:本次|本场)?(?:剩余|还剩|还有)(' + DIGITS + ')次' + TARGET + '机会$'),
new RegExp('^(?:本次|本场)?还可(?:补考|重考)' + SEPARATOR + '(' + DIGITS + ')次$'),
// The saved site's showButton2 label is “不及格,还可补考 N 次”.
// The template may wrap it in parentheses after the normal retake button.
new RegExp('^不及格[,,]还可补考(' + DIGITS + ')次$'),
new RegExp('^(?:重新答题|重新考试)?[((]不及格[,,]还可补考(' + DIGITS + ')次[))]$')
];
function clean(value) {
return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/[0-9]/g, char => String(char.charCodeAt(0) - 0xff10)).replace(/\s+/g, ' ').trim();
}
function visible(node) {
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
const view = node.ownerDocument && node.ownerDocument.defaultView;
if (!view) return false;
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
const style = view.getComputedStyle(current);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;
}
return node.getClientRects().length > 0 || view.getComputedStyle(node).display === 'contents';
}
function numberValue(raw) {
if (/^[0-9]+$/.test(raw)) return Number(raw);
if (/^[零〇]+$/.test(raw)) return 0;
if (raw === '一') return 1;
// Only zero and one affect this policy. Other explicit Chinese counts still
// form part of the signature but are never mistaken for a single attempt.
return 2;
}
function evidenceText(scope) {
const candidates = new Set();
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1 || node.matches(IGNORE) || !visible(node)) return '';
if (node.tagName === 'BR') return '\n';
const text = Array.from(node.childNodes, walk).join(' ');
// Small DOM rows permit labels and values in separate inline spans while
// whole pages and unrelated paragraphs are never treated as one label.
if (text.length <= 240) candidates.add(clean(text));
return BLOCK.test(node.tagName) ? '\n' + text + '\n' : text;
}
const lines = walk(scope).split(/[\n,,;;。!!]+/).map(clean).filter(Boolean);
lines.forEach(line => { if (line.length <= 240) candidates.add(line); });
// Definition lists commonly render a status label and its value separately.
scope.querySelectorAll('dt').forEach(label => {
const value = label.nextElementSibling;
if (value && value.tagName === 'DD' && visible(label) && visible(value) && !label.closest(IGNORE) && !value.closest(IGNORE)) {
candidates.add(clean(walk(label) + ' ' + walk(value)));
}
});
return candidates;
}
function read(scope) {
const empty = { blocked: false, known: false, count: null, reason: '', signature: '[]' };
// The caller must pass the current exam/quiz container. Reading Document or
// body could borrow another course's limits or a hidden background dialog.
if (!scope || scope.nodeType !== 1 || /^(?:HTML|BODY)$/.test(scope.tagName) || !visible(scope) || scope.closest(IGNORE)) return empty;
const evidence = new Set();
evidenceText(scope).forEach(raw => {
const value = raw.replace(/\s+/g, '').replace(/[::]$/, '');
for (const expression of forms) {
const match = value.match(expression);
if (!match) continue;
evidence.add(JSON.stringify([value, numberValue(match[1])]));
break;
}
});
const labels = Array.from(evidence).sort();
const values = labels.map(item => JSON.parse(item)[1]);
const count = values.includes(0) ? 0 : values.includes(1) ? 1 : null;
return {
blocked: count !== null,
known: labels.length > 0,
count: count,
reason: count === 0 ? '网页明确提示已无可用答题或考试次数,等待人工处理' : count === 1 ? '网页明确提示仅允许或剩余 1 次答题或考试机会,等待人工处理' : '',
signature: JSON.stringify(labels.map(item => JSON.parse(item)))
};
}
const api = { read: read };
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.YianAttemptPolicy = api;
})(typeof window === 'object' ? window : typeof globalThis === 'object' ? globalThis : this);