feat: support all page URLs and iframe shortcut forwarding
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# 华谊题库助手
|
||||
|
||||
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.8**。
|
||||
支持在普通 HTTP / HTTPS 网页及获准访问的本地 HTML 文件中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.9**。
|
||||
|
||||
1.2.9 明确匹配全部 HTTP、HTTPS 和本地 `file:` 页面。焦点在内嵌 iframe 时,**Shift + Alt + Q** 也能控制顶层的同一个面板;iframe 中不另建面板、不采集或保存数据。主页面在 window 捕获阶段处理快捷键,避免页面在 document 层拦截后失效。保持每页默认隐藏、自动答题默认关闭,保留1.2.8的重置题库功能。
|
||||
|
||||
1.2.8 新增“重置全部题库”,用于清除旧版或错误导入的数据。确认后清空本地全部题库、答案、冲突和四类历史修复备份,取消正在进行的导入及待保存任务,停止自动选择和判断题补齐,并暂停采集。新的题库存储与重置前隔离,旧标签页或旧版脚本再次写入的数据不会混回。升级本身保留已有资料,需要清空时再手动重置。
|
||||
|
||||
@@ -39,7 +41,9 @@
|
||||
5. 在“文字搜索”中输入或粘贴至少两个字符,按回车或点“搜索”。也可以在页面划选题干后松开鼠标自动查询。
|
||||
6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。已识别的练习页默认当前题库;普通网页默认全部已存题库。
|
||||
|
||||
浏览器需允许油猴在相应网站运行用户脚本。更新到全网页版本后,浏览器可能需要重新授予网站访问权限。若快捷键没有显示面板,先确认扩展已启用且该站点有运行权限。浏览器内置页面、扩展商店及受限制页面不能运行普通用户脚本;本地文件取决于浏览器的文件访问设置,本版匹配普通 HTTP / HTTPS 页面。
|
||||
浏览器需允许油猴在相应网站运行用户脚本。Chrome 的扩展详情中,将油猴“网站访问权限”设为允许所有网站,并按浏览器提示启用用户脚本权限;本地 HTML 还需要开启“允许访问文件网址”。若快捷键没有显示面板,先确认扩展、脚本和该站点访问权限均已启用。浏览器内置页面(如 `chrome://`)、扩展页及扩展商店等受限页面不能运行普通用户脚本;这些限制无法通过扩大匹配规则解除。
|
||||
|
||||
面板和本地题库搜索适用于上述全部网址;自动选择仍需页面题干、选项及控件可识别,华谊判断题补齐仍仅适用于原平台正常练习。内嵌页面只转发显示/隐藏快捷键,检索时可将题干输入或粘贴到顶层面板。
|
||||
|
||||
## 紧凑白色面板与重复记录修复
|
||||
|
||||
@@ -108,7 +112,7 @@
|
||||
|
||||
## 清除旧数据并重新导入
|
||||
|
||||
1. 将油猴中的原脚本完整更新为 [`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)(1.2.8),保存并刷新网页。
|
||||
1. 将油猴中的原脚本完整更新为 [`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)(1.2.9),保存并刷新网页。
|
||||
2. 按 **Shift + Alt + Q** 显示面板。在“题库 / 导出”的“已保存题库”下方,或“设置”顶部,点击 **重置全部题库**;油猴菜单也有同名入口。
|
||||
3. 确认提示会说明清除范围。如需保留旧资料,先取消并点击“备份 JSON”。确认重置后,本地题数归零,四类历史修复备份一起清除,正在读取的导入文件也不会在稍后写回。
|
||||
4. 点击“导入多个题库”,选择仓库中已核验的 [`合并题库JSON`](题库/华谊集团化工总控工(高级)-合并题库.json) 或 [`合并CSV`](题库/华谊集团化工总控工(高级)-合并题库.csv),导入后应为 **1884题**。只需选择其中一种;也可导入所需的分库文件。
|
||||
|
||||
@@ -4,11 +4,12 @@ const root = __dirname;
|
||||
const header = `// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.8
|
||||
// @version 1.2.9
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @match http://*/*
|
||||
// @match https://*/*
|
||||
// @match file:///*
|
||||
// @run-at document-start
|
||||
// @noframes
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
|
||||
Vendored
+25
-5
@@ -1,11 +1,12 @@
|
||||
// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.8
|
||||
// @version 1.2.9
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @match http://*/*
|
||||
// @match https://*/*
|
||||
// @match file:///*
|
||||
// @run-at document-start
|
||||
// @noframes
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
@@ -2375,6 +2376,19 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
// ---- src/app.js ----
|
||||
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
|
||||
(() => {
|
||||
const FRAME_SHORTCUT = 'huayi-qb.frame-shortcut.v1';
|
||||
const isToggleShortcut = event => event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey &&
|
||||
(event.code === 'KeyQ' || (!event.code && String(event.key).toLowerCase() === 'q')) && !event.repeat;
|
||||
// Frames only forward the explicit shortcut. Keep one panel and one set of
|
||||
// storage/network handlers in the top page; never relay frame text or answers.
|
||||
if (window.top !== window.self) {
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!isToggleShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
window.top.postMessage({ type: FRAME_SHORTCUT }, '*');
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
|
||||
@@ -3352,11 +3366,17 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) {
|
||||
window.addEventListener('keydown', event => {
|
||||
if (isToggleShortcut(event)) {
|
||||
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data?.type !== FRAME_SHORTCUT || !event.source || event.source === window) return;
|
||||
try {
|
||||
if (event.source.top === window || event.source.top === pageWindow) setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||
} catch (_) { /* Ignore messages from unrelated or inaccessible windows. */ }
|
||||
});
|
||||
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
|
||||
window.addEventListener('pageshow', event => {
|
||||
synchronizeReset();
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "huayi-question-bank",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"private": true,
|
||||
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"merge:banks": "node scripts/merge-question-banks.cjs",
|
||||
"select:snapshots": "node scripts/select-bank-snapshots.cjs",
|
||||
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/collector.test.cjs tests/select-bank-snapshots.test.cjs",
|
||||
"test:browser": "node --test tests/integration.test.cjs tests/reset-browser.test.cjs",
|
||||
"test:browser": "node --test tests/integration.test.cjs tests/reset-browser.test.cjs tests/global-browser.test.cjs",
|
||||
"test:performance": "node --test tests/performance-browser.test.cjs",
|
||||
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
|
||||
}
|
||||
|
||||
+21
-2
@@ -1,5 +1,18 @@
|
||||
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
|
||||
(() => {
|
||||
const FRAME_SHORTCUT = 'huayi-qb.frame-shortcut.v1';
|
||||
const isToggleShortcut = event => event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey &&
|
||||
(event.code === 'KeyQ' || (!event.code && String(event.key).toLowerCase() === 'q')) && !event.repeat;
|
||||
// Frames only forward the explicit shortcut. Keep one panel and one set of
|
||||
// storage/network handlers in the top page; never relay frame text or answers.
|
||||
if (window.top !== window.self) {
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!isToggleShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
window.top.postMessage({ type: FRAME_SHORTCUT }, '*');
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
|
||||
@@ -977,11 +990,17 @@
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) {
|
||||
window.addEventListener('keydown', event => {
|
||||
if (isToggleShortcut(event)) {
|
||||
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data?.type !== FRAME_SHORTCUT || !event.source || event.source === window) return;
|
||||
try {
|
||||
if (event.source.top === window || event.source.top === pageWindow) setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||
} catch (_) { /* Ignore messages from unrelated or inaccessible windows. */ }
|
||||
});
|
||||
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
|
||||
window.addEventListener('pageshow', event => {
|
||||
synchronizeReset();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
// Local browser fixtures cover URL protocols and frame focus without opening
|
||||
// any production site or depending on a user's extension configuration.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const record = { bankName: '全网址题库', bankId: 'global-bank', questionId: 'global-question', type: 'single',
|
||||
stem: '全网页核验题的标准答案是什么?', options: [{ id: 'a', label: 'A', text: '正确内容' }, { id: 'b', label: 'B', text: '其他内容' }],
|
||||
correctOptionIds: ['a'], answerTexts: ['正确内容'], answerStatus: 'confirmed' };
|
||||
function installGM(record) {
|
||||
window.unsafeWindow = window;
|
||||
const values = new Map([['huayi-qb.records.v1', [record]], ['huayi-qb.config.v1', { collecting: true }]]);
|
||||
window.__globalMenus = [];
|
||||
window.GM_getValue = (key, fallback) => structuredClone(values.has(key) ? values.get(key) : fallback);
|
||||
window.GM_setValue = (key, value) => values.set(key, structuredClone(value));
|
||||
window.GM_deleteValue = key => values.delete(key);
|
||||
window.GM_registerMenuCommand = (name, action) => window.__globalMenus.push({ name, action });
|
||||
window.GM_addValueChangeListener = () => 1;
|
||||
window.GM_removeValueChangeListener = () => {};
|
||||
window.__originalFetch = window.fetch;
|
||||
}
|
||||
|
||||
test('全网址:HTTP、HTTPS、内网地址、本地文件及iframe快捷键', { timeout: 45000 }, async t => {
|
||||
let playwright;
|
||||
try { playwright = require(process.env.QB_PLAYWRIGHT_MODULE || 'playwright'); }
|
||||
catch { playwright = require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
|
||||
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
|
||||
const executable = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH || (fs.existsSync(cached) ? cached : undefined);
|
||||
const browser = await playwright.chromium.launch({ headless: true, ...(executable ? { executablePath: executable } : {}) });
|
||||
const context = await browser.newContext();
|
||||
const errors = [];
|
||||
const script = fs.readFileSync(path.join(ROOT, 'dist/huayi-question-bank.user.js'), 'utf8');
|
||||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'huayi-qb-global-'));
|
||||
const localFile = path.join(localDir, 'index.html');
|
||||
fs.writeFileSync(localFile, '<!doctype html><html><body><p>本地HTML验收</p></body></html>', 'utf8');
|
||||
try {
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
let content = '<p>普通网页验收</p>';
|
||||
if (url.pathname === '/framed') content = '<iframe src="https://child.example.test/child" id="child"></iframe>';
|
||||
else if (url.pathname === '/child') content = '<input id="frame-input"><iframe src="https://nested.example.test/nested"></iframe>';
|
||||
else if (url.pathname === '/nested') content = '<input id="frame-input">';
|
||||
if (url.pathname === '/blocks-document') content += '<script>document.addEventListener("keydown",event=>event.stopImmediatePropagation(),true)</script>';
|
||||
return route.fulfill({ status: 200, contentType: 'text/html', body: `<!doctype html><html><body>${content}</body></html>` });
|
||||
});
|
||||
await context.addInitScript({ content: `(${installGM.toString()})(${JSON.stringify(record)});\n${script}` });
|
||||
context.on('page', page => page.on('pageerror', error => errors.push(error.message)));
|
||||
await t.test('元数据覆盖三种协议,并允许iframe接收快捷键', () => {
|
||||
const patterns = [...script.matchAll(/^\/\/ @match\s+(\S+)$/gm)].map(match => match[1]);
|
||||
assert.deepEqual(patterns, ['http://*/*', 'https://*/*', 'file:///*']);
|
||||
assert.equal(/^\/\/ @noframes\b/m.test(script), false);
|
||||
});
|
||||
for (const url of ['http://public.example.test/path', 'https://public.example.test/path?x=1#part',
|
||||
'http://127.0.0.1:52345/local', 'https://intranet:8443/path', pathToFileURL(localFile).href]) {
|
||||
await t.test(`${url.startsWith('file:') ? '本地file页面' : url}:默认隐藏、快捷键显示、搜索已导入标准答案`, async () => {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(url);
|
||||
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
await page.keyboard.press('Alt+Shift+Q');
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||
await page.locator('#qb-search').fill('全网页核验题');
|
||||
await page.locator('#qb-search').press('Enter');
|
||||
assert.match(await page.locator('#qb-results').innerText(), /正确内容/);
|
||||
assert.equal(await page.evaluate(() => window.fetch === window.__originalFetch), true, '普通页面不观察业务请求');
|
||||
await page.keyboard.press('Alt+Shift+Q');
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
}
|
||||
await t.test('网页在document阻止键盘传播时仍可唤出,缺少code的Q键也可用', async () => {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto('https://public.example.test/blocks-document');
|
||||
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||
await page.keyboard.press('Alt+Shift+Q');
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||
await page.evaluate(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Q', altKey: true, shiftKey: true })));
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
await t.test('跨域及嵌套iframe中按快捷键,控制顶层唯一面板且子页不启动存储或采集', async () => {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto('https://public.example.test/framed');
|
||||
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||
const child = page.frame({ url: 'https://child.example.test/child' });
|
||||
const nested = page.frame({ url: 'https://nested.example.test/nested' });
|
||||
assert.ok(child); assert.ok(nested);
|
||||
for (const frame of [child, nested]) {
|
||||
assert.equal(await frame.locator('#huayi-qb-root').count(), 0);
|
||||
assert.equal(await frame.evaluate(() => window.__globalMenus.length), 0);
|
||||
assert.equal(await frame.evaluate(() => window.fetch === window.__originalFetch), true);
|
||||
}
|
||||
await child.locator('#frame-input').press('Alt+Shift+Q');
|
||||
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||
await nested.locator('#frame-input').press('Alt+Shift+Q');
|
||||
await page.locator('#qb-panel').waitFor({ state: 'hidden' });
|
||||
assert.equal(await page.locator('#huayi-qb-root').count(), 1);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await context.close(); await browser.close();
|
||||
fs.unlinkSync(localFile); fs.rmdirSync(localDir);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user