Files
yianxue-assistant/attempt-policy.js

107 lines
6.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);