fix: rename assistant and gate exports behind collapsible switch

This commit is contained in:
薇星幻海
2026-10-10 04:14:09 +08:00
parent 0ea302bbab
commit ed24cf18d0
8 changed files with 145 additions and 60 deletions
+4 -2
View File
@@ -5,7 +5,7 @@
## 安装和更新 ## 安装和更新
1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。 1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。
2. 新建脚本,将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存。升级时替换原脚本全文,不要同时启用新旧两份脚本。 2. 新建脚本,将 `谊安学助手 2.0.user.js` 的全部内容粘贴进去,保存。脚本显示名称为“谊安学助手 2.0”。升级时替换原脚本全文,不要同时启用新旧两份脚本。
3. 刷新网页,点击右下角“题库助手”入口展开面板。 3. 刷新网页,点击右下角“题库助手”入口展开面板。
4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。 4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。
@@ -34,7 +34,9 @@
## 网页题目导出 ## 网页题目导出
进入兼容练习页后,默认等待题目和参考答案加载完整,再自动下载 CSV;可在面板关闭自动导出,或点击 CSV / JSON / TXT 按钮手动导出。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。 “导出题库”是独立的折叠开关,每次刷新默认关闭,升级前的自动导出设置也不会使它自动开启。关闭时导出区收起,自动下载和手动导出均禁用。
开启“导出题库”后展开 CSV / JSON / TXT 按钮。在兼容练习页中,题目和参考答案加载完整后会自动下载一次 CSV,也可点击格式按钮手动导出。再次关闭后停止后续导出;主动重新开启可再次下载当前题库。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。 - CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
- 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。 - 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。
+29 -15
View File
@@ -21,7 +21,7 @@
const state = { const state = {
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [], autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面', bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
}; };
let destroyed = false; let destroyed = false;
let selectionText = ''; let selectionText = '';
@@ -43,7 +43,7 @@
visibilityStyle.textContent = '[hidden]{display:none!important}'; visibilityStyle.textContent = '[hidden]{display:none!important}';
root.appendChild(visibilityStyle); root.appendChild(visibilityStyle);
const panel = el(doc, 'section', 'panel collapsed'); const panel = el(doc, 'section', 'panel collapsed');
panel.setAttribute('aria-label', '谊安学题库助手'); panel.setAttribute('aria-label', '谊安学助手 2.0');
const header = el(doc, 'div', 'header'); const header = el(doc, 'div', 'header');
const brand = el(doc, 'span', 'brand', '题库助手'); const brand = el(doc, 'span', 'brand', '题库助手');
const expand = el(doc, 'button', 'toggle', '展开'); const expand = el(doc, 'button', 'toggle', '展开');
@@ -76,7 +76,7 @@
brand.addEventListener('click', togglePanel); brand.addEventListener('click', togglePanel);
const switches = el(doc, 'div', 'switches'); const switches = el(doc, 'div', 'switches');
function makeSwitch(labelText, callback) { function makeSwitch(labelText, callback, container) {
const label = el(doc, 'label', 'switch-row'); const label = el(doc, 'label', 'switch-row');
const text = el(doc, 'span', '', labelText); const text = el(doc, 'span', '', labelText);
const input = el(doc, 'input', 'switch'); const input = el(doc, 'input', 'switch');
@@ -84,7 +84,7 @@
input.setAttribute('role', 'switch'); input.setAttribute('role', 'switch');
input.setAttribute('aria-label', labelText); input.setAttribute('aria-label', labelText);
label.append(text, input); label.append(text, input);
switches.appendChild(label); (container || switches).appendChild(label);
input.addEventListener('change', callback); input.addEventListener('change', callback);
return { input: input, label: label }; return { input: input, label: label };
} }
@@ -227,22 +227,35 @@
const unprocessedList = el(doc, 'div', 'unprocessed-list'); const unprocessedList = el(doc, 'div', 'unprocessed-list');
unprocessed.append(unprocessedSummary, unprocessedList); unprocessed.append(unprocessedSummary, unprocessedList);
unprocessed.hidden = true; unprocessed.hidden = true;
pageSection.append(pageStatus, metrics, unprocessed);
body.appendChild(pageSection);
const exportSection = el(doc, 'section', 'page-section export-section');
exportSection.setAttribute('aria-label', '题库导出设置');
const exportSwitch = makeSwitch('导出题库', function () {
state.exportEnabled = exportSwitch.input.checked;
refresh();
invoke('onExportEnabledChange', state.exportEnabled);
}, exportSection);
const exportContent = el(doc, 'div', 'export-content');
exportContent.id = 'yian-export-content';
exportContent.hidden = true;
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
const exportRow = el(doc, 'div', 'exports'); const exportRow = el(doc, 'div', 'exports');
const exportButtons = []; const exportButtons = [];
['csv', 'json', 'txt'].forEach(function (format) { ['csv', 'json', 'txt'].forEach(function (format) {
const button = el(doc, 'button', '', '导出 ' + format.toUpperCase()); const button = el(doc, 'button', '', '导出 ' + format.toUpperCase());
button.type = 'button'; button.type = 'button';
button.addEventListener('click', function () { invoke('onExport', format); }); button.addEventListener('click', function () {
if (state.exportEnabled && state.canExport) invoke('onExport', format);
});
exportRow.appendChild(button); exportRow.appendChild(button);
exportButtons.push(button); exportButtons.push(button);
}); });
const autoExportLabel = el(doc, 'label', 'auto-export'); exportContent.appendChild(exportRow);
const autoExportInput = el(doc, 'input'); exportSection.appendChild(exportContent);
autoExportInput.type = 'checkbox'; body.appendChild(exportSection);
autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV'));
autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); });
pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel);
body.appendChild(pageSection);
function autoUnavailableReason() { function autoUnavailableReason() {
if (!state.role) return '请选择岗位后再开启自动选择。'; if (!state.role) return '请选择岗位后再开启自动选择。';
@@ -260,7 +273,7 @@
if (destroyed) return; if (destroyed) return;
panel.classList.toggle('collapsed', !state.expanded); panel.classList.toggle('collapsed', !state.expanded);
body.hidden = !state.expanded; body.hidden = !state.expanded;
brand.textContent = state.expanded ? '谊安学助手' : '题库助手'; brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
expand.textContent = state.expanded ? '收起' : '展开'; expand.textContent = state.expanded ? '收起' : '展开';
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手'); expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
expand.setAttribute('aria-expanded', String(Boolean(state.expanded))); expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
@@ -310,8 +323,9 @@
notProcessed.forEach(function (item) { notProcessed.forEach(function (item) {
unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'))); unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
}); });
exportButtons.forEach(function (button) { button.disabled = !state.canExport; }); exportSwitch.input.checked = Boolean(state.exportEnabled);
autoExportInput.checked = Boolean(state.autoExport); exportContent.hidden = !state.exportEnabled;
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
previous.disabled = currentOffset === 0 || Boolean(state.loading); previous.disabled = currentOffset === 0 || Boolean(state.loading);
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading); next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
} }
+4 -4
View File
@@ -1,9 +1,9 @@
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
const metadata = `// ==UserScript== const metadata = `// ==UserScript==
// @name 谊安学练习题库自动导出 // @name 谊安学助手 2.0
// @namespace yianxue-question-bank // @namespace yianxue-question-bank
// @version 2.0.0 // @version 2.0.1
// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。 // @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
// @match *://*/* // @match *://*/*
// @match file:///* // @match file:///*
@@ -25,5 +25,5 @@ const zipWrapper = '(function () { const module = { exports: {} }; const exports
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js']; 'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js'];
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n'); const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8'); writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8');
console.log('已生成 谊安学题库导出.user.js'); console.log('已生成 谊安学助手 2.0.user.js');
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "yianxue-question-bank-exporter", "name": "yianxue-question-bank-exporter",
"version": "2.0.0", "version": "2.0.1",
"private": true, "private": true,
"description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择", "description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择",
"scripts": { "scripts": {
+16 -8
View File
@@ -4,7 +4,7 @@
const core = window.QuestionBankCore; const core = window.QuestionBankCore;
const store = window.YianBankStore; const store = window.YianBankStore;
const matcher = window.YianAnswerMatcher; const matcher = window.YianAnswerMatcher;
const config = { autoAnswer: false, allowCopy: true, autoExport: true, role: '', course: '' }; const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' }; let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
let loading = true, disposed = false, page = null, previous = null; let loading = true, disposed = false, page = null, previous = null;
let searchQuery = '', runKey = '', debounceTimer; let searchQuery = '', runKey = '', debounceTimer;
@@ -61,7 +61,7 @@
} }
function refreshPanel(extra) { function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(), exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {})); bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
} }
function performSearch(query = searchQuery, options = {}) { function performSearch(query = searchQuery, options = {}) {
@@ -106,6 +106,7 @@
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); } } finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
} }
function exportCurrent(format = 'csv') { function exportCurrent(format = 'csv') {
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
const route = routeInfo(); const route = routeInfo();
if (!route.practice) throw new Error('请先进入支持的练习页面'); if (!route.practice) throw new Error('请先进入支持的练习页面');
const bank = core.extract(document, route.sourceUrl); const bank = core.extract(document, route.sourceUrl);
@@ -115,6 +116,13 @@
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning'); + ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
return bank; return bank;
} }
function setExportEnabled(enabled) {
const opening = !!enabled && !config.exportEnabled;
config.exportEnabled = !!enabled;
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
if (opening) exported.clear();
refreshPanel(); tick(); return config.exportEnabled;
}
const panel = window.YianAssistantPanel.create({ const panel = window.YianAssistantPanel.create({
onAutoChange: setAutoAnswer, onCopyChange: setCopy, onAutoChange: setAutoAnswer, onCopyChange: setCopy,
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); }, onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
@@ -122,7 +130,7 @@
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch, onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
onSelectionSearch: text => performSearch(text), onSelectionSearch: text => performSearch(text),
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } }, onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); } onExportEnabledChange: setExportEnabled
}); });
function tick() { function tick() {
@@ -160,11 +168,11 @@
refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total, refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] }); canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; } if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
if (config.autoExport && bank.complete) { if (config.exportEnabled && bank.complete) {
const key = route.key + '|' + signature; const key = route.key + '|' + signature;
if (!exported.has(key)) { if (!exported.has(key)) {
try { exportCurrent('csv'); exported.add(key); } try { exportCurrent('csv'); exported.add(key); }
catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); } catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
} }
} }
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision; const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
@@ -188,13 +196,13 @@
} }
const ready = (async () => { const ready = (async () => {
try { try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]); const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : ''; snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false; config.allowCopy = values[2] !== false;
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot; loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
})(); })();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext, const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }), getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose }); exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
window.YianAssistant = api; window.YianQuestionBankExporter = api; window.YianAssistant = api; window.YianQuestionBankExporter = api;
+6 -2
View File
@@ -6,9 +6,10 @@ const os = require('node:os');
const http = require('node:http'); const http = require('node:http');
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..'); const workspace = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(workspace, '谊安学题库导出.user.js'), 'utf8'); const script = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
assert(script.includes('// @match *://*/*')); assert(script.includes('// @match *://*/*'));
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script)); assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
assert(script.includes('// @name 谊安学助手 2.0'));
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa'); const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa');
fs.mkdirSync(qaDir, { recursive: true }); fs.mkdirSync(qaDir, { recursive: true });
@@ -48,7 +49,7 @@ function documentHTML(practice) {
} }
(async () => { (async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', false]]); const memory = new Map([['yianxue.assistant.setting.v2.autoExport', true], ['yianxue.assistant.setting.v2.exportEnabled', true]]);
let clipboard = ''; let clipboard = '';
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
@@ -76,6 +77,8 @@ function documentHTML(practice) {
assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证'); assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证');
assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible()); assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible());
await page.getByRole('button', { name: '展开题库助手' }).click(); await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled()); assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined'); assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容'); console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
@@ -134,6 +137,7 @@ function documentHTML(practice) {
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`; const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready); await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.waitForFunction(() => YianAssistant.getState().pageReady); await second.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关');
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total); assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操'); assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false); assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
+38 -3
View File
@@ -8,7 +8,7 @@ const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8'); const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8');
const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8'); const scriptSource = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa'); const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa');
fs.mkdirSync(qaDir, { recursive: true }); fs.mkdirSync(qaDir, { recursive: true });
const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html'); const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html');
@@ -68,9 +68,21 @@ function parseCSV(text) {
const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise'; const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise';
await page.goto(practiceURL('模拟练习')); await page.goto(practiceURL('模拟练习'));
await page.evaluate(() => { window.answerClicks = 0; }); await page.evaluate(() => { window.answerClicks = 0; });
const firstDownload = page.waitForEvent('download');
await page.addScriptTag({ content: scriptSource }); await page.addScriptTag({ content: scriptSource });
await page.evaluate(() => YianAssistant.ready);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
await page.waitForTimeout(800);
assert.equal(downloads.length, 0, '导出开关默认关闭,不应自动下载');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
const blockedMessage = await page.evaluate(() => {
try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; }
});
assert(blockedMessage.includes('开关'), '开关关闭时导出入口必须拒绝手动下载');
const firstDownload = page.waitForEvent('download');
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
const downloaded = await firstDownload; const downloaded = await firstDownload;
assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isVisible());
await downloaded.saveAs(path.join(qaDir, 'synthetic.csv')); await downloaded.saveAs(path.join(qaDir, 'synthetic.csv'));
const bank = await page.evaluate(() => QuestionBankCore.extract(document)); const bank = await page.evaluate(() => QuestionBankCore.extract(document));
assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true); assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true);
@@ -140,6 +152,26 @@ function parseCSV(text) {
assert.equal(await page.evaluate(() => window.answerClicks), 0); assert.equal(await page.evaluate(() => window.answerClicks), 0);
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态'); console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
const beforeClosedRoute = downloads.length;
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
}, { url: practiceURL('开关关闭练习'), html: fixture('开关关闭练习') });
await page.waitForFunction(() => YianAssistant.getState().pageReady);
await page.waitForTimeout(800);
assert.equal(downloads.length, beforeClosedRoute, '开关关闭时新练习也不自动导出');
const reopening = page.waitForEvent('download');
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
assert.equal((await reopening).suggestedFilename(), '开关关闭练习_题库_3题.csv');
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
const closedManual = await page.evaluate(() => {
try { YianAssistant.exportCurrent('json'); return ''; } catch (error) { return error.message; }
});
assert(closedManual.includes('开关'));
console.log('PASS 导出默认关闭与折叠、关闭后换页不下载、手动入口拒绝、开启才导出');
const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture(); const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture();
const tool = await context.newPage(); const tool = await context.newPage();
tool.on('pageerror', error => errors.push(error.message)); tool.on('pageerror', error => errors.push(error.message));
@@ -183,7 +215,10 @@ function parseCSV(text) {
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习'); const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习');
const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML); const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML);
await local.goto(require('node:url').pathToFileURL(localPath).href); await local.goto(require('node:url').pathToFileURL(localPath).href);
const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource }); await local.addScriptTag({ content: scriptSource });
await local.evaluate(() => YianAssistant.ready);
const localDownload = local.waitForEvent('download');
await local.getByRole('switch', { name: '导出题库', exact: true }).check();
assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv'); assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv');
assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n')); assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n'));
console.log('PASS 本地保存网页自动识别与浏览器错误检查'); console.log('PASS 本地保存网页自动识别与浏览器错误检查');
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name 谊安学练习题库自动导出 // @name 谊安学助手 2.0
// @namespace yianxue-question-bank // @namespace yianxue-question-bank
// @version 2.0.0 // @version 2.0.1
// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。 // @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
// @match *://*/* // @match *://*/*
// @match file:///* // @match file:///*
@@ -1070,7 +1070,7 @@ window.JSZip = module.exports;
const state = { const state = {
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [], autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面', bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
}; };
let destroyed = false; let destroyed = false;
let selectionText = ''; let selectionText = '';
@@ -1092,7 +1092,7 @@ window.JSZip = module.exports;
visibilityStyle.textContent = '[hidden]{display:none!important}'; visibilityStyle.textContent = '[hidden]{display:none!important}';
root.appendChild(visibilityStyle); root.appendChild(visibilityStyle);
const panel = el(doc, 'section', 'panel collapsed'); const panel = el(doc, 'section', 'panel collapsed');
panel.setAttribute('aria-label', '谊安学题库助手'); panel.setAttribute('aria-label', '谊安学助手 2.0');
const header = el(doc, 'div', 'header'); const header = el(doc, 'div', 'header');
const brand = el(doc, 'span', 'brand', '题库助手'); const brand = el(doc, 'span', 'brand', '题库助手');
const expand = el(doc, 'button', 'toggle', '展开'); const expand = el(doc, 'button', 'toggle', '展开');
@@ -1125,7 +1125,7 @@ window.JSZip = module.exports;
brand.addEventListener('click', togglePanel); brand.addEventListener('click', togglePanel);
const switches = el(doc, 'div', 'switches'); const switches = el(doc, 'div', 'switches');
function makeSwitch(labelText, callback) { function makeSwitch(labelText, callback, container) {
const label = el(doc, 'label', 'switch-row'); const label = el(doc, 'label', 'switch-row');
const text = el(doc, 'span', '', labelText); const text = el(doc, 'span', '', labelText);
const input = el(doc, 'input', 'switch'); const input = el(doc, 'input', 'switch');
@@ -1133,7 +1133,7 @@ window.JSZip = module.exports;
input.setAttribute('role', 'switch'); input.setAttribute('role', 'switch');
input.setAttribute('aria-label', labelText); input.setAttribute('aria-label', labelText);
label.append(text, input); label.append(text, input);
switches.appendChild(label); (container || switches).appendChild(label);
input.addEventListener('change', callback); input.addEventListener('change', callback);
return { input: input, label: label }; return { input: input, label: label };
} }
@@ -1276,22 +1276,35 @@ window.JSZip = module.exports;
const unprocessedList = el(doc, 'div', 'unprocessed-list'); const unprocessedList = el(doc, 'div', 'unprocessed-list');
unprocessed.append(unprocessedSummary, unprocessedList); unprocessed.append(unprocessedSummary, unprocessedList);
unprocessed.hidden = true; unprocessed.hidden = true;
pageSection.append(pageStatus, metrics, unprocessed);
body.appendChild(pageSection);
const exportSection = el(doc, 'section', 'page-section export-section');
exportSection.setAttribute('aria-label', '题库导出设置');
const exportSwitch = makeSwitch('导出题库', function () {
state.exportEnabled = exportSwitch.input.checked;
refresh();
invoke('onExportEnabledChange', state.exportEnabled);
}, exportSection);
const exportContent = el(doc, 'div', 'export-content');
exportContent.id = 'yian-export-content';
exportContent.hidden = true;
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
const exportRow = el(doc, 'div', 'exports'); const exportRow = el(doc, 'div', 'exports');
const exportButtons = []; const exportButtons = [];
['csv', 'json', 'txt'].forEach(function (format) { ['csv', 'json', 'txt'].forEach(function (format) {
const button = el(doc, 'button', '', '导出 ' + format.toUpperCase()); const button = el(doc, 'button', '', '导出 ' + format.toUpperCase());
button.type = 'button'; button.type = 'button';
button.addEventListener('click', function () { invoke('onExport', format); }); button.addEventListener('click', function () {
if (state.exportEnabled && state.canExport) invoke('onExport', format);
});
exportRow.appendChild(button); exportRow.appendChild(button);
exportButtons.push(button); exportButtons.push(button);
}); });
const autoExportLabel = el(doc, 'label', 'auto-export'); exportContent.appendChild(exportRow);
const autoExportInput = el(doc, 'input'); exportSection.appendChild(exportContent);
autoExportInput.type = 'checkbox'; body.appendChild(exportSection);
autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV'));
autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); });
pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel);
body.appendChild(pageSection);
function autoUnavailableReason() { function autoUnavailableReason() {
if (!state.role) return '请选择岗位后再开启自动选择。'; if (!state.role) return '请选择岗位后再开启自动选择。';
@@ -1309,7 +1322,7 @@ window.JSZip = module.exports;
if (destroyed) return; if (destroyed) return;
panel.classList.toggle('collapsed', !state.expanded); panel.classList.toggle('collapsed', !state.expanded);
body.hidden = !state.expanded; body.hidden = !state.expanded;
brand.textContent = state.expanded ? '谊安学助手' : '题库助手'; brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
expand.textContent = state.expanded ? '收起' : '展开'; expand.textContent = state.expanded ? '收起' : '展开';
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手'); expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
expand.setAttribute('aria-expanded', String(Boolean(state.expanded))); expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
@@ -1359,8 +1372,9 @@ window.JSZip = module.exports;
notProcessed.forEach(function (item) { notProcessed.forEach(function (item) {
unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'))); unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
}); });
exportButtons.forEach(function (button) { button.disabled = !state.canExport; }); exportSwitch.input.checked = Boolean(state.exportEnabled);
autoExportInput.checked = Boolean(state.autoExport); exportContent.hidden = !state.exportEnabled;
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
previous.disabled = currentOffset === 0 || Boolean(state.loading); previous.disabled = currentOffset === 0 || Boolean(state.loading);
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading); next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
} }
@@ -1852,7 +1866,7 @@ window.JSZip = module.exports;
const core = window.QuestionBankCore; const core = window.QuestionBankCore;
const store = window.YianBankStore; const store = window.YianBankStore;
const matcher = window.YianAnswerMatcher; const matcher = window.YianAnswerMatcher;
const config = { autoAnswer: false, allowCopy: true, autoExport: true, role: '', course: '' }; const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' }; let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
let loading = true, disposed = false, page = null, previous = null; let loading = true, disposed = false, page = null, previous = null;
let searchQuery = '', runKey = '', debounceTimer; let searchQuery = '', runKey = '', debounceTimer;
@@ -1909,7 +1923,7 @@ window.JSZip = module.exports;
} }
function refreshPanel(extra) { function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(), exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {})); bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
} }
function performSearch(query = searchQuery, options = {}) { function performSearch(query = searchQuery, options = {}) {
@@ -1954,6 +1968,7 @@ window.JSZip = module.exports;
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); } } finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
} }
function exportCurrent(format = 'csv') { function exportCurrent(format = 'csv') {
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
const route = routeInfo(); const route = routeInfo();
if (!route.practice) throw new Error('请先进入支持的练习页面'); if (!route.practice) throw new Error('请先进入支持的练习页面');
const bank = core.extract(document, route.sourceUrl); const bank = core.extract(document, route.sourceUrl);
@@ -1963,6 +1978,13 @@ window.JSZip = module.exports;
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning'); + ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
return bank; return bank;
} }
function setExportEnabled(enabled) {
const opening = !!enabled && !config.exportEnabled;
config.exportEnabled = !!enabled;
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
if (opening) exported.clear();
refreshPanel(); tick(); return config.exportEnabled;
}
const panel = window.YianAssistantPanel.create({ const panel = window.YianAssistantPanel.create({
onAutoChange: setAutoAnswer, onCopyChange: setCopy, onAutoChange: setAutoAnswer, onCopyChange: setCopy,
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); }, onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
@@ -1970,7 +1992,7 @@ window.JSZip = module.exports;
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch, onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
onSelectionSearch: text => performSearch(text), onSelectionSearch: text => performSearch(text),
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } }, onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); } onExportEnabledChange: setExportEnabled
}); });
function tick() { function tick() {
@@ -2008,11 +2030,11 @@ window.JSZip = module.exports;
refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total, refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] }); canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; } if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
if (config.autoExport && bank.complete) { if (config.exportEnabled && bank.complete) {
const key = route.key + '|' + signature; const key = route.key + '|' + signature;
if (!exported.has(key)) { if (!exported.has(key)) {
try { exportCurrent('csv'); exported.add(key); } try { exportCurrent('csv'); exported.add(key); }
catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); } catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
} }
} }
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision; const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
@@ -2036,13 +2058,13 @@ window.JSZip = module.exports;
} }
const ready = (async () => { const ready = (async () => {
try { try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]); const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : ''; snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false; config.allowCopy = values[2] !== false;
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot; loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
})(); })();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext, const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }), getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose }); exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
window.YianAssistant = api; window.YianQuestionBankExporter = api; window.YianAssistant = api; window.YianQuestionBankExporter = api;