From a9986a8b7f1acc3d53ca1f67f7ed2c3ee3f76f12 Mon Sep 17 00:00:00 2001 From: suanyuit-cyber <346708943+suanyuit-cyber@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:24:30 +0800 Subject: [PATCH] feat: support all page URLs and iframe shortcut forwarding --- README.md | 10 ++- build.cjs | 7 +- dist/huayi-question-bank.user.js | 30 ++++++-- package.json | 4 +- src/app.js | 23 ++++++- tests/global-browser.test.cjs | 113 +++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 tests/global-browser.test.cjs diff --git a/README.md b/README.md index 765c83f..52e0340 100644 --- a/README.md +++ b/README.md @@ -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题**。只需选择其中一种;也可导入所需的分库文件。 diff --git a/build.cjs b/build.cjs index 4c92239..039bd38 100644 --- a/build.cjs +++ b/build.cjs @@ -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 diff --git a/dist/huayi-question-bank.user.js b/dist/huayi-question-bank.user.js index 3733308..ef1e944 100644 --- a/dist/huayi-question-bank.user.js +++ b/dist/huayi-question-bank.user.js @@ -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(); diff --git a/package.json b/package.json index 838e11f..e1b0c5b 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/app.js b/src/app.js index cf3e881..c95a6cd 100644 --- a/src/app.js +++ b/src/app.js @@ -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(); diff --git a/tests/global-browser.test.cjs b/tests/global-browser.test.cjs new file mode 100644 index 0000000..e9a8c84 --- /dev/null +++ b/tests/global-browser.test.cjs @@ -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, '

本地HTML验收

', 'utf8'); + try { + await context.route('**/*', route => { + const url = new URL(route.request().url()); + let content = '

普通网页验收

'; + if (url.pathname === '/framed') content = ''; + else if (url.pathname === '/child') content = ''; + else if (url.pathname === '/nested') content = ''; + if (url.pathname === '/blocks-document') content += ''; + return route.fulfill({ status: 200, contentType: 'text/html', body: `${content}` }); + }); + 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); + } +});