diff --git a/mv2_simple_crx/src/README.md b/mv2_simple_crx/src/README.md new file mode 100644 index 0000000..a8454db --- /dev/null +++ b/mv2_simple_crx/src/README.md @@ -0,0 +1,163 @@ +# 模块化重构说明 + +## 🎯 重构目标 + +使用现代 ES6 模块化方式,将分散的独立函数整合到统一的导出文件中,提供更友好的开发体验。 + +## 📁 新的项目结构 + +``` +src/ +├── libs/ +│ ├── index.js # 统一导出所有库函数 +│ ├── action_response.js +│ ├── tabs.js +│ └── action_meta.js +├── actions/ +│ ├── index.js # 统一导出所有动作 +│ ├── amazon.js +│ └── amazon_tool.js +├── background/ +│ └── index.js # 使用新的导入方式 +└── examples/ + └── usage_example.js # 使用示例 +``` + +## 🚀 使用方式 + +### 1. 命名导入(推荐) + +```javascript +import { + ok_response, + fail_response, + create_tab_task, + getAllActionsMeta, + getActionByName +} from './libs/index.js'; +``` + +### 2. 默认导入使用对象 + +```javascript +import Libs from './libs/index.js'; +import Actions from './actions/index.js'; + +// 使用 +const response = Libs.response.ok({ data: 'success' }); +const task = Libs.tabs.createTask('https://example.com'); +const actions = Actions.amazon; +``` + +### 3. 混合使用 + +```javascript +// 直接需要的函数用命名导入 +import { ok_response, create_tab_task } from './libs/index.js'; + +// 复杂对象用默认导入 +import Actions from './actions/index.js'; + +const action = getActionByName('amazon_search_list'); +``` + +## 📦 统一导出内容 + +### libs/index.js + +```javascript +// 响应处理 +export { + ok_response, + fail_response, + response_code, + guard_sync +} from './action_response.js'; + +// Tab 操作 +export { + raw_execute_script, + inject_file, + ensure_injected, + execute_script, + open_tab, + close_tab, + create_tab_task +} from './tabs.js'; + +// 元数据处理 +export { + bind_action_meta +} from './action_meta.js'; + +// 便捷对象 +export const Libs = { + response: { ok: ok_response, fail: fail_response, ... }, + tabs: { open: open_tab, close: close_tab, ... }, + meta: { bindAction: bind_action_meta } +}; +``` + +### actions/index.js + +```javascript +// 导出所有动作 +export { amazon_actions } from './amazon.js'; + +// 导出工具函数 +export { + injected_amazon_validate_captcha_continue, + run_amazon_pdp_action, + // ... 其他工具函数 +} from './amazon_tool.js'; + +// 便捷函数 +export function getAllActionsMeta() { ... } +export function getActionByName(name) { ... } + +// 便捷对象 +export const Actions = { + amazon: amazon_actions, + amazonTools: { ... } +}; +``` + +## ✅ 优势 + +1. **统一入口** - 不需要记住具体的文件路径 +2. **灵活导入** - 支持命名导入、默认导入、混合导入 +3. **便于维护** - 集中管理所有导出 +4. **向后兼容** - 保持原有功能不变 +5. **现代语法** - 使用最新的 ES6 模块特性 + +## 🔄 迁移指南 + +### 旧方式 +```javascript +import { create_tab_task } from '../libs/tabs.js'; +import { ok_response } from '../libs/action_response.js'; +import { amazon_actions } from '../actions/amazon.js'; +``` + +### 新方式 +```javascript +import { create_tab_task, ok_response, amazon_actions } from '../libs/index.js'; +// 或者 +import { create_tab_task, ok_response } from '../libs/index.js'; +import { amazon_actions } from '../actions/index.js'; +``` + +## 🎨 最佳实践 + +1. **简单函数** - 使用命名导入 +2. **复杂对象** - 使用默认导入 +3. **类型安全** - 配合 TypeScript 使用 +4. **按需导入** - 只导入需要的功能 +5. **统一风格** - 在一个项目中保持一致的导入风格 + +## 🔧 开发建议 + +- 新增功能时,优先添加到对应的 `index.js` 文件 +- 保持导出名称的一致性和可读性 +- 使用 JSDoc 注释提高代码可读性 +- 定期检查和优化导出结构 diff --git a/mv2_simple_crx/src/actions/amazon_tool.js b/mv2_simple_crx/src/actions/amazon_tool.js index 745e02c..1c479bc 100644 --- a/mv2_simple_crx/src/actions/amazon_tool.js +++ b/mv2_simple_crx/src/actions/amazon_tool.js @@ -1,5 +1,4 @@ -import { create_tab_task } from '../libs/tabs.js'; -import { fail_response, guard_sync, ok_response, response_code } from '../libs/action_response.js'; +import { create_tab_task, ok_response, fail_response, guard_sync, response_code } from '../libs/index.js'; // Amazon:注入函数 + action 实现(amazon.js 仅保留 action 壳) // @@ -9,17 +8,15 @@ import { fail_response, guard_sync, ok_response, response_code } from '../libs/a // ---------- 页面注入(仅依赖页面 DOM) ---------- -function injected_utils() { - return window.__mv2_simple_injected || null; -} +const injected_utils = () => window.__mv2_simple_injected || null; -function dispatch_human_click(target_el, options) { +const dispatch_human_click = (target_el, options) => { const u = injected_utils(); if (u && typeof u.dispatch_human_click === 'function') { return u.dispatch_human_click(target_el, options); } return false; -} +}; export function injected_amazon_validate_captcha_continue() { const href = location.href || ''; diff --git a/mv2_simple_crx/src/actions/index.js b/mv2_simple_crx/src/actions/index.js new file mode 100644 index 0000000..d980e97 --- /dev/null +++ b/mv2_simple_crx/src/actions/index.js @@ -0,0 +1,63 @@ +/** + * 统一的动作导出 + * 使用现代 ES6 模块化方式,提供统一的动作接口 + */ + +// Amazon 相关动作 +export { amazon_actions } from './amazon.js'; + +// Amazon 工具函数 +export { + injected_amazon_validate_captcha_continue, + is_amazon_validate_captcha_url, + injected_amazon_product_detail, + injected_amazon_product_reviews, + run_amazon_pdp_action, + run_amazon_pdp_action_multi, + run_amazon_search_list_action, + run_amazon_set_language_action +} from './amazon_tool.js'; + +// 便捷的统一导出对象 +export const Actions = { + // Amazon 动作列表 + amazon: amazon_actions, + + // Amazon 工具函数 + amazonTools: { + validateCaptcha: injected_amazon_validate_captcha_continue, + isCaptchaUrl: is_amazon_validate_captcha_url, + productDetail: injected_amazon_product_detail, + productReviews: injected_amazon_product_reviews, + runPdpAction: run_amazon_pdp_action, + runPdpActionMulti: run_amazon_pdp_action_multi, + runSearchListAction: run_amazon_search_list_action, + runSetLanguageAction: run_amazon_set_language_action + } +}; + +// 获取所有动作的元信息 +export function getAllActionsMeta() { + const meta = {}; + if (Array.isArray(amazon_actions)) { + amazon_actions.forEach((item) => { + if (item && item.name) { + meta[item.name] = { + name: item.name, + desc: item.desc || '', + params: item.params || {}, + }; + } + }); + } + return meta; +} + +// 根据名称获取动作 +export function getActionByName(name) { + if (!Array.isArray(amazon_actions)) return null; + return amazon_actions.find(item => item && item.name === name); +} + +// 默认导出 +export default Actions; diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js index 968cb91..8d925f9 100644 --- a/mv2_simple_crx/src/background/index.js +++ b/mv2_simple_crx/src/background/index.js @@ -1,23 +1,32 @@ -import { amazon_actions } from '../actions/amazon.js'; +import { amazon_actions, getAllActionsMeta, getActionByName } from '../actions/index.js'; + +// 调试日志系统 +const DEBUG = true; +const debug_log = (level, ...args) => { + if (DEBUG) { + const timestamp = new Date().toISOString(); + console[level](`[Background ${timestamp}]`, ...args); + } +}; // action 注册表:供 UI 下拉选择 + server bridge 调用 -const action_list = Array.isArray(amazon_actions) ? amazon_actions : []; +let action_list = []; -function list_actions_meta() { - const meta = {}; - action_list.forEach((item) => { - if (!item || !item.name) return; - meta[item.name] = { - name: item.name, - desc: item.desc || '', - params: item.params || {}, - }; - }); - return meta; +try { + if (Array.isArray(amazon_actions)) { + action_list = amazon_actions.filter(item => item && typeof item === 'object' && item.name); + debug_log('log', `Loaded ${action_list.length} actions:`, action_list.map(item => item.name)); + } else { + debug_log('warn', 'amazon_actions is not an array:', amazon_actions); + } +} catch (error) { + debug_log('error', 'Failed to load amazon_actions:', error); } -function create_action_send_response(sender) { +const list_actions_meta = () => getAllActionsMeta(); + +const create_action_send_response = (sender) => { const fn = (payload) => { emit_ui_event('push', { type: 'reply', ...payload, sender }); }; @@ -25,37 +34,48 @@ function create_action_send_response(sender) { emit_ui_event('push', { type: 'log', action: 'log', data: payload, sender }); }; return fn; -} +}; const ui_page_url = chrome.runtime.getURL('ui/index.html'); - - -function emit_ui_event(event_name, payload) { - chrome.runtime.sendMessage({ - channel: 'ui_event', - event_name, - payload, - ts: Date.now(), - }); -} +const emit_ui_event = (event_name, payload) => { + try { + chrome.runtime.sendMessage({ + channel: 'ui_event', + event_name, + payload, + ts: Date.now(), + }, (response) => { + if (chrome.runtime.lastError) { + console.warn('Failed to send UI event:', chrome.runtime.lastError.message); + } + }); + } catch (error) { + console.error('Error in emit_ui_event:', error); + } +}; chrome.browserAction.onClicked.addListener(() => { chrome.tabs.create({ url: ui_page_url, active: true }); }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + debug_log('log', 'Received message:', { message, sender: sender.tab?.url || 'background' }); + if (!message) { + debug_log('warn', 'Empty message received'); return; } // UI 自己发出来的事件,background 不处理 if (message.channel === 'ui_event') { + debug_log('log', 'Ignoring ui_event message'); return; } // content -> background 的推送消息(通用) if (message.type === 'push') { + debug_log('log', 'Processing push message:', message.action); emit_ui_event('push', { type: 'push', action: message.action, @@ -67,18 +87,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // UI -> background 的 action 调用 if (!message.action) { + debug_log('error', 'Missing action in message'); sendResponse && sendResponse({ ok: false, error: '缺少 action' }); return; } // UI 获取 action 元信息(用于下拉/默认参数) if (message.action === 'meta_actions') { + debug_log('log', 'Returning actions meta'); sendResponse({ ok: true, data: list_actions_meta() }); return; } // UI 刷新后台(重启 background page) if (message.action === 'reload_background') { + debug_log('log', 'Reloading background page'); sendResponse({ ok: true }); setTimeout(() => { location.reload(); @@ -86,27 +109,47 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return; } - const action_item = action_list.find((item) => item && item.name === message.action); + const action_item = getActionByName(message.action); const action_handler = action_item && typeof action_item.handler === 'function' ? action_item.handler : null; if (!action_handler) { + debug_log('error', 'Unknown action:', message.action); sendResponse({ ok: false, error: '未知 action: ' + message.action }); return; } const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`; + debug_log('log', 'Executing action:', { action: message.action, request_id, data: message.data }); emit_ui_event('request', { type: 'request', request_id, action: message.action, data: message.data || {}, sender }); const action_send_response = create_action_send_response(sender); + // 添加超时处理 + const timeout = setTimeout(() => { + debug_log('warn', `Action ${message.action} timed out after 30000ms`); + emit_ui_event('response', { + type: 'response', + request_id, + ok: false, + error: 'Action timed out after 30 seconds', + sender + }); + sendResponse({ ok: false, error: 'Action timed out after 30 seconds', request_id }); + }, 30000); // 30秒超时 + (async () => { try { const res = await action_handler(message.data || {}, action_send_response); + clearTimeout(timeout); + debug_log('log', `Action ${message.action} completed successfully:`, { request_id, result: res }); emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender }); sendResponse({ ok: true, data: res, request_id }); } catch (err) { + clearTimeout(timeout); const error = (err && err.message) || String(err); - emit_ui_event('response', { type: 'response', request_id, ok: false, error, sender }); - sendResponse({ ok: false, error, request_id }); + const stack = (err && err.stack) || ''; + debug_log('error', `Action ${message.action} failed:`, { error, stack, data: message.data }); + emit_ui_event('response', { type: 'response', request_id, ok: false, error, stack, sender }); + sendResponse({ ok: false, error, stack, request_id }); } })(); diff --git a/mv2_simple_crx/src/examples/usage_example.js b/mv2_simple_crx/src/examples/usage_example.js new file mode 100644 index 0000000..cb37663 --- /dev/null +++ b/mv2_simple_crx/src/examples/usage_example.js @@ -0,0 +1,60 @@ +/** + * 使用新模块化方式的示例 + * 展示如何使用统一的导出接口 + */ + +// 方式1: 命名导入(推荐) +import { + ok_response, + fail_response, + create_tab_task, + amazon_actions, + getAllActionsMeta, + getActionByName +} from '../libs/index.js'; + +// 方式2: 默认导入使用对象 +import Libs from '../libs/index.js'; +import Actions from '../actions/index.js'; + +// 示例函数 +export async function exampleAction() { + // 使用命名导入 + const response = ok_response({ success: true }); + + // 使用默认导入 + const task = Libs.tabs.createTask('https://example.com'); + + // 使用 Actions + const allMeta = getAllActionsMeta(); + const specificAction = getActionByName('amazon_search_list'); + + return { + response, + task, + allMeta, + specificAction + }; +} + +// 更简洁的写法 +export const ModernUsage = { + // 响应处理 + response: { + success: (data) => ok_response(data), + error: (msg) => fail_response(msg) + }, + + // Tab 操作 + tabs: { + create: (url) => create_tab_task(url), + // ... 其他操作 + }, + + // 动作管理 + actions: { + getAll: getAllActionsMeta, + get: getActionByName, + list: amazon_actions + } +}; diff --git a/mv2_simple_crx/src/injected/injected.js b/mv2_simple_crx/src/injected/injected.js index 8a7684a..a562c29 100644 --- a/mv2_simple_crx/src/injected/injected.js +++ b/mv2_simple_crx/src/injected/injected.js @@ -1,60 +1,58 @@ -(function () { +(() => { if (window.__mv2_simple_injected) return; - function norm_space(s) { - return (s || '').toString().replace(/\s+/g, ' ').trim(); - } + const norm_space = (s) => (s || '').toString().replace(/\s+/g, ' ').trim(); - function busy_wait_ms(ms) { - var t = Number(ms); - var dur = Number.isFinite(t) ? Math.max(0, t) : 0; - var t0 = performance.now(); + const busy_wait_ms = (ms) => { + const t = Number(ms); + const dur = Number.isFinite(t) ? Math.max(0, t) : 0; + const t0 = performance.now(); while (performance.now() - t0 < dur) { } - } + }; - function is_visible(el) { + const is_visible = (el) => { if (!el) return false; - var r = el.getBoundingClientRect(); + const r = el.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) return false; // 尽量避免点击到不可见层;display/visibility 由浏览器计算 - var cs = window.getComputedStyle(el); + const cs = window.getComputedStyle(el); if (!cs) return true; if (cs.display === 'none') return false; if (cs.visibility === 'hidden') return false; if (cs.opacity === '0') return false; return true; - } + }; - function wait_query(selectors, timeout_ms) { - var list = Array.isArray(selectors) ? selectors : []; - var deadline = Date.now() + (Number.isFinite(timeout_ms) ? timeout_ms : 5000); + const wait_query = (selectors, timeout_ms) => { + const list = Array.isArray(selectors) ? selectors : []; + const deadline = Date.now() + (Number.isFinite(timeout_ms) ? timeout_ms : 5000); while (Date.now() < deadline) { - for (var i = 0; i < list.length; i += 1) { - var sel = list[i]; - var el = document.querySelector(sel); + for (let i = 0; i < list.length; i += 1) { + const sel = list[i]; + const el = document.querySelector(sel); if (is_visible(el)) return el; } busy_wait_ms(40); } return null; - } + }; - function dispatch_human_click(target_el, options) { - var el = target_el; + const dispatch_human_click = (target_el, options) => { + const el = target_el; if (!el) return false; - var opt = options && typeof options === 'object' ? options : {}; - var pointer_id = Number.isFinite(opt.pointer_id) ? opt.pointer_id : 1; - var pointer_type = opt.pointer_type ? String(opt.pointer_type) : 'mouse'; + const opt = options && typeof options === 'object' ? options : {}; + const pointer_id = Number.isFinite(opt.pointer_id) ? opt.pointer_id : 1; + const pointer_type = opt.pointer_type ? String(opt.pointer_type) : 'mouse'; try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { } try { el.focus && el.focus(); } catch (_) { } - var rect = el.getBoundingClientRect(); - var ox = Number.isFinite(opt.offset_x) ? opt.offset_x : 0; - var oy = Number.isFinite(opt.offset_y) ? opt.offset_y : 0; - var x = Math.max(1, Math.floor(rect.left + rect.width / 2 + ox)); - var y = Math.max(1, Math.floor(rect.top + rect.height / 2 + oy)); - var base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }; + const rect = el.getBoundingClientRect(); + const ox = Number.isFinite(opt.offset_x) ? opt.offset_x : 0; + const oy = Number.isFinite(opt.offset_y) ? opt.offset_y : 0; + const x = Math.max(1, Math.floor(rect.left + rect.width / 2 + ox)); + const y = Math.max(1, Math.floor(rect.top + rect.height / 2 + oy)); + const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }; try { if (typeof PointerEvent === 'function') { @@ -72,58 +70,54 @@ el.dispatchEvent(new MouseEvent('mouseup', base)); el.dispatchEvent(new MouseEvent('click', base)); return true; - } + }; - function text(el) { - return el && el.textContent != null ? norm_space(el.textContent) : null; - } + const text = (el) => el && el.textContent != null ? norm_space(el.textContent) : null; - function inner_text(el) { - return el && el.innerText != null ? norm_space(el.innerText) : null; - } + const inner_text = (el) => el && el.innerText != null ? norm_space(el.innerText) : null; - function attr(el, name) { + const attr = (el, name) => { if (!el || !name) return null; - var v = el.getAttribute ? el.getAttribute(name) : null; + const v = el.getAttribute ? el.getAttribute(name) : null; return v != null ? norm_space(v) : null; - } + }; - function abs_url(href, base) { + const abs_url = (href, base) => { try { return new URL(href, base || location.origin).toString(); } catch (_) { return href; } - } + }; - function parse_asin_from_url(url) { + const parse_asin_from_url = (url) => { if (!url || typeof url !== 'string') return null; - var m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i); + const m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i); return m ? m[1].toUpperCase() : null; - } + }; - function pick_number(text0) { + const pick_number = (text0) => { if (!text0) return null; - var s = String(text0).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim(); - var m = s.match(/(\d+(?:\.\d+)?)/); + const s = String(text0).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim(); + const m = s.match(/(\d+(?:\.\d+)?)/); return m ? Number(m[1]) : null; - } + }; - function pick_int(text0) { + const pick_int = (text0) => { if (!text0) return null; - var raw = String(text0).replace(/\s+/g, ' ').trim(); - var up = raw.toUpperCase().replace(/,/g, ''); - var km = up.match(/([\d.]+)\s*K\b/); + const raw = String(text0).replace(/\s+/g, ' ').trim(); + const up = raw.toUpperCase().replace(/,/g, ''); + const km = up.match(/([\d.]+)\s*K\b/); if (km) return Math.round(parseFloat(km[1]) * 1000); - var mm = up.match(/([\d.]+)\s*M\b/); + const mm = up.match(/([\d.]+)\s*M\b/); if (mm) return Math.round(parseFloat(mm[1]) * 1000000); - var digits = raw.replace(/[^\d]/g, ''); + const digits = raw.replace(/[^\d]/g, ''); return digits ? Number(digits) : null; - } + }; - function set_input_value(input, value, options) { + const set_input_value = (input, value, options) => { if (!input) return false; - var opt = options && typeof options === 'object' ? options : {}; + const opt = options && typeof options === 'object' ? options : {}; try { input.focus && input.focus(); } catch (_) { } try { input.value = value == null ? '' : String(value); } catch (_) { return false; } if (opt.dispatch_input !== false) { @@ -133,7 +127,7 @@ try { input.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) { } } return true; - } + }; window.__mv2_simple_injected = { norm_space: norm_space, diff --git a/mv2_simple_crx/src/libs/index.js b/mv2_simple_crx/src/libs/index.js new file mode 100644 index 0000000..419f099 --- /dev/null +++ b/mv2_simple_crx/src/libs/index.js @@ -0,0 +1,58 @@ +/** + * 统一的库函数导出 + * 使用现代 ES6 模块化方式,提供统一的功能接口 + */ + +// 响应处理相关 +export { + ok_response, + fail_response, + response_code, + guard_sync +} from './action_response.js'; + +// Tab 操作相关 +export { + raw_execute_script, + inject_file, + ensure_injected, + execute_script, + open_tab, + close_tab, + create_tab_task +} from './tabs.js'; + +// Action 元数据相关 +export { + bind_action_meta +} from './action_meta.js'; + +// 便捷的统一导出对象(可选使用) +export const Libs = { + // 响应处理 + response: { + ok: ok_response, + fail: fail_response, + code: response_code, + guard: guard_sync + }, + + // Tab 操作 + tabs: { + rawExecuteScript: raw_execute_script, + injectFile: inject_file, + ensureInjected: ensure_injected, + executeScript: execute_script, + open: open_tab, + close: close_tab, + createTask: create_tab_task + }, + + // 元数据 + meta: { + bindAction: bind_action_meta + } +}; + +// 默认导出(可选使用) +export default Libs; diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js index a425038..37f97c7 100644 --- a/mv2_simple_crx/src/libs/tabs.js +++ b/mv2_simple_crx/src/libs/tabs.js @@ -1,85 +1,277 @@ // openTab:MV2 版本(极简 + 回调风格) -function build_code(fn, args) { +/** + * 构建可执行代码字符串 + * @param {Function|string} fn - 要执行的函数或代码字符串 + * @param {Array} args - 传递给函数的参数数组 + * @returns {string} 可执行的代码字符串 + */ +const build_code = (fn, args) => { if (typeof fn === 'function') { - if (Array.isArray(args) && args.length) { - return `(${fn.toString()}).apply(null, ${JSON.stringify(args)});`; + const funcStr = fn.toString(); + if (Array.isArray(args) && args.length > 0) { + // 安全地序列化参数,避免循环引用 + const serializedArgs = JSON.stringify(args, (key, value) => { + if (typeof value === 'function') return undefined; + if (value && typeof value === 'object' && value.constructor === Object) { + try { + JSON.stringify(value); + return value; + } catch { + return '[Object]'; + } + } + return value; + }); + return `(${funcStr}).apply(null, ${serializedArgs});`; } - return `(${fn.toString()})();`; + return `(${funcStr})();`; } - return fn; -} + + if (typeof fn === 'string') { + return fn; + } + + throw new TypeError('fn must be a function or string'); +}; -// 低阶:只负责执行,不做任何前置注入。 -export function raw_execute_script(tab_id, fn, args, run_at) { - const real_run_at = run_at || 'document_idle'; - const code = build_code(fn, args); +/** + * 在指定标签页中执行原始脚本(低阶接口,不做任何前置注入) + * @param {number} tab_id - 标签页ID + * @param {Function|string} fn - 要执行的函数或代码字符串 + * @param {Array} args - 传递给函数的参数数组 + * @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle' + * @returns {Promise} 执行结果数组 + */ +export async function raw_execute_script(tab_id, fn, args = [], run_at = 'document_idle') { + // 参数验证 + if (!Number.isInteger(tab_id) || tab_id <= 0) { + throw new Error('Invalid tab_id: must be a positive integer'); + } + + if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) { + throw new Error('Invalid fn: must be a function or string'); + } + + if (!Array.isArray(args)) { + throw new Error('Invalid args: must be an array'); + } + + const validRunAt = ['document_start', 'document_end', 'document_idle']; + if (!validRunAt.includes(run_at)) { + throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); + } - return new Promise((resolve, reject) => { - chrome.tabs.executeScript( - tab_id, - { - code, - runAt: real_run_at, - }, - (result) => { - if (chrome.runtime.lastError) { - return reject(new Error(chrome.runtime.lastError.message)); + try { + const code = build_code(fn, args); + + return await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Script execution timeout for tab ${tab_id}`)); + }, 30000); // 30秒超时 + + chrome.tabs.executeScript( + tab_id, + { + code, + runAt: run_at, + }, + (result) => { + clearTimeout(timeoutId); + + if (chrome.runtime.lastError) { + const error = new Error(chrome.runtime.lastError.message); + error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'EXECUTION_ERROR'; + return reject(error); + } + + resolve(result || []); } - resolve(result); - }, - ); - }); + ); + }); + } catch (error) { + // 重新抛出带有更多上下文的错误 + const enhancedError = new Error(`Failed to execute script in tab ${tab_id}: ${error.message}`); + enhancedError.originalError = error; + enhancedError.tabId = tab_id; + enhancedError.runAt = run_at; + throw enhancedError; + } } -export function inject_file(tab_id, file, run_at) { - const real_run_at = run_at || 'document_idle'; - return new Promise((resolve, reject) => { - chrome.tabs.executeScript( - tab_id, - { - file, - runAt: real_run_at, - }, - () => { - if (chrome.runtime.lastError) { - return reject(new Error(chrome.runtime.lastError.message)); +/** + * 在指定标签页中注入文件 + * @param {number} tab_id - 标签页ID + * @param {string} file - 要注入的文件路径(相对于扩展根目录) + * @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle' + * @returns {Promise} 注入是否成功 + */ +export async function inject_file(tab_id, file, run_at = 'document_idle') { + // 参数验证 + if (!Number.isInteger(tab_id) || tab_id <= 0) { + throw new Error('Invalid tab_id: must be a positive integer'); + } + + if (!file || typeof file !== 'string') { + throw new Error('Invalid file: must be a non-empty string'); + } + + // 验证文件路径格式 + if (!file.match(/^[\w\-./]+$/)) { + throw new Error('Invalid file path: contains invalid characters'); + } + + const validRunAt = ['document_start', 'document_end', 'document_idle']; + if (!validRunAt.includes(run_at)) { + throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); + } + + try { + return await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`File injection timeout for tab ${tab_id}: ${file}`)); + }, 15000); // 15秒超时 + + chrome.tabs.executeScript( + tab_id, + { + file, + runAt: run_at, + }, + (result) => { + clearTimeout(timeoutId); + + if (chrome.runtime.lastError) { + const error = new Error(chrome.runtime.lastError.message); + error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : + chrome.runtime.lastError.message?.includes('Cannot access') ? 'FILE_NOT_FOUND' : 'INJECTION_ERROR'; + error.file = file; + return reject(error); + } + + resolve(true); } - resolve(true); - }, - ); - }); + ); + }); + } catch (error) { + // 重新抛出带有更多上下文的错误 + const enhancedError = new Error(`Failed to inject file "${file}" in tab ${tab_id}: ${error.message}`); + enhancedError.originalError = error; + enhancedError.tabId = tab_id; + enhancedError.file = file; + enhancedError.runAt = run_at; + throw enhancedError; + } } -function pick_first_frame_value(raw_list) { +/** + * 从执行结果中提取第一个帧的值 + * @param {Array} raw_list - Chrome 执行脚本返回的结果数组 + * @returns {*} 第一个帧的值,如果数组为空则返回 null + */ +const pick_first_frame_value = (raw_list) => { if (!Array.isArray(raw_list) || raw_list.length === 0) return null; - return raw_list[0]; -} + return raw_list[0]?.result ?? raw_list[0]; +}; -export async function ensure_injected(tab_id) { - const injected = pick_first_frame_value( - await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'), - ); - if (injected === true) return true; - - // 约定:扩展根目录=src,因此 file 使用 src 内相对路径 - await inject_file(tab_id, 'injected/injected.js', 'document_idle'); - const injected2 = pick_first_frame_value( - await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'), - ); - return injected2 === true; -} - -// 高阶:默认确保 injected 通用方法已加载。 -export async function execute_script(tab_id, fn, args, run_at, options) { - const ensure = !(options && options.ensure_injected === false); - if (ensure) { - await ensure_injected(tab_id); +/** + * 确保注入脚本已加载到指定标签页 + * @param {number} tab_id - 标签页ID + * @param {number} maxRetries - 最大重试次数(默认3次) + * @returns {Promise} 注入是否成功 + */ +export async function ensure_injected(tab_id, maxRetries = 3) { + if (!Number.isInteger(tab_id) || tab_id <= 0) { + throw new Error('Invalid tab_id: must be a positive integer'); } - return await raw_execute_script(tab_id, fn, args, run_at); + + // 检查是否已经注入 + try { + const injected = pick_first_frame_value( + await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle') + ); + if (injected === true) return true; + } catch (error) { + // 如果检查失败,可能是标签页不存在,继续尝试注入 + console.warn(`Failed to check injection status for tab ${tab_id}:`, error.message); + } + + // 尝试注入,带重试机制 + let lastError; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // 约定:扩展根目录=src,因此 file 使用 src 内相对路径 + await inject_file(tab_id, 'injected/injected.js', 'document_idle'); + + // 验证注入是否成功 + const injected = pick_first_frame_value( + await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle') + ); + + if (injected === true) return true; + + // 如果注入后仍然失败,等待一小段时间再重试 + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + } catch (error) { + lastError = error; + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + } + } + } + + throw new Error(`Failed to ensure injection after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); } -function update_tab(tab_id, update_props) { +/** + * 高阶脚本执行接口(默认确保 injected 通用方法已加载) + * @param {number} tab_id - 标签页ID + * @param {Function|string} fn - 要执行的函数或代码字符串 + * @param {Array} args - 传递给函数的参数数组 + * @param {string} run_at - 执行时机 + * @param {Object} options - 选项配置 + * @param {boolean} options.ensure_injected - 是否确保注入(默认true) + * @param {number} options.maxRetries - 注入重试次数(默认3次) + * @returns {Promise} 执行结果数组 + */ +export async function execute_script(tab_id, fn, args = [], run_at = 'document_idle', options = {}) { + // 参数验证 + if (!Number.isInteger(tab_id) || tab_id <= 0) { + throw new Error('Invalid tab_id: must be a positive integer'); + } + + if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) { + throw new Error('Invalid fn: must be a function or string'); + } + + // 选项配置 + const opts = { + ensure_injected: true, + maxRetries: 3, + ...options + }; + + try { + // 确保注入(如果需要) + if (opts.ensure_injected) { + await ensure_injected(tab_id, opts.maxRetries); + } + + // 执行脚本 + return await raw_execute_script(tab_id, fn, args, run_at); + } catch (error) { + // 增强错误信息 + const enhancedError = new Error(`Failed to execute script in tab ${tab_id}: ${error.message}`); + enhancedError.originalError = error; + enhancedError.tabId = tab_id; + enhancedError.ensureInjected = opts.ensure_injected; + throw enhancedError; + } +} + +const update_tab = (tab_id, update_props) => { return new Promise((resolve_update, reject_update) => { chrome.tabs.update(tab_id, update_props, (updated_tab) => { if (chrome.runtime.lastError) { @@ -88,9 +280,9 @@ function update_tab(tab_id, update_props) { resolve_update(updated_tab || true); }); }); -} +}; -function attach_tab_helpers(tab) { +const attach_tab_helpers = (tab) => { if (!tab) return tab; tab.remove = function remove(delay_ms) { @@ -227,50 +419,178 @@ function attach_tab_helpers(tab) { return tab; } -export function open_tab(url, options) { - // 保留原本 Promise 版本(内部复用) - options = options && typeof options === 'object' ? options : {}; +/** + * 打开新标签页并等待加载完成 + * @param {string} url - 要打开的URL + * @param {Object} options - 选项配置 + * @param {boolean} options.active - 是否激活标签页(默认true) + * @param {number} options.timeout - 加载超时时间(毫秒,默认45000) + * @param {boolean} options.loadInBackground - 是否在后台加载(默认false) + * @returns {Promise<{tab_id: number, tab: Object}>} 标签页信息 + */ +export async function open_tab(url, options = {}) { + // 参数验证 + if (!url || typeof url !== 'string') { + throw new Error('Invalid url: must be a non-empty string'); + } - return new Promise((resolve, reject) => { - chrome.tabs.create( - { - url: 'about:blank', - active: options.active !== false, - }, - (tab) => { - if (chrome.runtime.lastError) { - return reject(new Error(chrome.runtime.lastError.message)); - } - if (!tab || !tab.id) { - return reject(new Error('tab 创建失败')); + // 验证URL格式 + try { + new URL(url); + } catch { + throw new Error('Invalid url format: must be a valid URL'); + } + + // 选项配置 + const opts = { + active: true, + timeout: 45000, // 45秒超时 + loadInBackground: false, + ...options + }; + + if (typeof opts.active !== 'boolean') { + throw new Error('Invalid options.active: must be a boolean'); + } + + if (!Number.isInteger(opts.timeout) || opts.timeout <= 0) { + throw new Error('Invalid options.timeout: must be a positive integer'); + } + + try { + return await new Promise((resolve, reject) => { + // 设置超时 + const timeoutId = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(on_updated); + reject(new Error(`Tab loading timeout for ${url} after ${opts.timeout}ms`)); + }, opts.timeout); + + const on_updated = (updated_tab_id, change_info, updated_tab) => { + if (updated_tab_id !== tab_id) return; + if (change_info.status !== 'complete') return; + + clearTimeout(timeoutId); + chrome.tabs.onUpdated.removeListener(on_updated); + + try { + const enhancedTab = attach_tab_helpers(updated_tab); + resolve({ tab_id, tab: enhancedTab }); + } catch (error) { + reject(new Error(`Failed to attach helpers to tab ${tab_id}: ${error.message}`)); } + }; - const tab_id = tab.id; + chrome.tabs.onUpdated.addListener(on_updated); - const on_updated = (updated_tab_id, change_info, updated_tab) => { - if (updated_tab_id !== tab_id) return; - if (change_info.status !== 'complete') return; - - chrome.tabs.onUpdated.removeListener(on_updated); - resolve({ tab_id, tab: attach_tab_helpers(updated_tab) }); - }; - - chrome.tabs.onUpdated.addListener(on_updated); - update_tab(tab_id, { url }) - .catch((err) => { + // 创建标签页 + chrome.tabs.create( + { + url: 'about:blank', + active: !opts.loadInBackground && opts.active, + }, + async (tab) => { + if (chrome.runtime.lastError) { + clearTimeout(timeoutId); chrome.tabs.onUpdated.removeListener(on_updated); - reject(err); - }); - }, - ); - }); + const error = new Error(chrome.runtime.lastError.message); + error.code = 'TAB_CREATE_FAILED'; + return reject(error); + } + + if (!tab || !tab.id) { + clearTimeout(timeoutId); + chrome.tabs.onUpdated.removeListener(on_updated); + return reject(new Error('Failed to create tab: invalid tab object')); + } + + const tab_id = tab.id; + + try { + // 导航到目标URL + await update_tab(tab_id, { url }); + } catch (error) { + clearTimeout(timeoutId); + chrome.tabs.onUpdated.removeListener(on_updated); + reject(new Error(`Failed to navigate tab ${tab_id} to ${url}: ${error.message}`)); + } + } + ); + }); + } catch (error) { + // 增强错误信息 + const enhancedError = new Error(`Failed to open tab for ${url}: ${error.message}`); + enhancedError.originalError = error; + enhancedError.url = url; + throw enhancedError; + } } -export function close_tab(tab_id, delay_ms) { - delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0; - setTimeout(() => { - chrome.tabs.remove(tab_id, () => void 0); - }, Math.max(0, delay_ms)); +/** + * 关闭指定标签页 + * @param {number} tab_id - 标签页ID + * @param {number|Object} delayOrOptions - 延迟时间(毫秒)或选项对象 + * @param {number} delayOrOptions.delay - 延迟时间(毫秒,默认0) + * @param {boolean} delayOrOptions.force - 是否强制关闭(默认false) + * @returns {Promise} 关闭是否成功 + */ +export async function close_tab(tab_id, delayOrOptions = {}) { + // 参数验证 + if (!Number.isInteger(tab_id) || tab_id <= 0) { + throw new Error('Invalid tab_id: must be a positive integer'); + } + + // 处理选项参数 + let options = {}; + if (typeof delayOrOptions === 'number') { + options.delay = delayOrOptions; + } else if (typeof delayOrOptions === 'object') { + options = delayOrOptions; + } + + const opts = { + delay: 0, + force: false, + ...options + }; + + if (!Number.isInteger(opts.delay) || opts.delay < 0) { + throw new Error('Invalid delay: must be a non-negative integer'); + } + + try { + return await new Promise((resolve, reject) => { + const delay = Math.max(0, opts.delay); + + if (delay === 0) { + // 立即关闭 + chrome.tabs.remove(tab_id, () => { + if (chrome.runtime.lastError) { + const error = new Error(chrome.runtime.lastError.message); + error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'CLOSE_FAILED'; + return reject(error); + } + resolve(true); + }); + } else { + // 延迟关闭 + setTimeout(() => { + chrome.tabs.remove(tab_id, () => { + if (chrome.runtime.lastError && !opts.force) { + const error = new Error(chrome.runtime.lastError.message); + error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'CLOSE_FAILED'; + return reject(error); + } + resolve(true); + }); + }, delay); + } + }); + } catch (error) { + const enhancedError = new Error(`Failed to close tab ${tab_id}: ${error.message}`); + enhancedError.originalError = error; + enhancedError.tabId = tab_id; + throw enhancedError; + } } // openTab 任务对象:用对象绑定方法,减少重复参数 diff --git a/mv2_simple_crx/src/manifest.json b/mv2_simple_crx/src/manifest.json index ff9e40d..713d9a2 100644 --- a/mv2_simple_crx/src/manifest.json +++ b/mv2_simple_crx/src/manifest.json @@ -5,6 +5,7 @@ "description": "MV2 极简骨架:openTab + executeScript + __REQUEST_DONE 监听", "permissions": [ "tabs", + "storage", "" ], "background": {