Compare commits

...

2 Commits

Author SHA1 Message Date
张成
66df49a7c3 1 2026-03-19 18:36:41 +08:00
张成
a776873477 1 2026-03-19 17:54:30 +08:00
14 changed files with 1424 additions and 602 deletions

View File

@@ -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 注释提高代码可读性
- 定期检查和优化导出结构

View File

@@ -1,358 +1,88 @@
// Amazonaction编排逻辑放这里),注入函数放 amazon_tool.js
// Amazonaction(编排逻辑移至 amazon_tool.js
import { create_tab_task } from '../libs/tabs.js';
import { fail_response, ok_response, response_code } from '../libs/action_response.js';
import {
injected_amazon_homepage_search,
injected_amazon_product_detail,
injected_amazon_product_reviews,
injected_amazon_search_list,
injected_amazon_switch_language,
injected_amazon_validate_captcha_continue,
normalize_product_url,
try_solve_amazon_validate_captcha,
wait_until_search_list_url,
run_amazon_pdp_action,
run_amazon_pdp_action_multi,
run_amazon_search_list_action,
run_amazon_set_language_action,
} from './amazon_tool.js';
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
const AMAZON_HOME_FOR_LANG =
'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN&currency=HKD';
export function amazon_search_list(data, sendResponse) {
return new Promise((resolve, reject) => {
const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : '';
const sort_by = data && data.sort_by ? String(data.sort_by).trim() : '';
const keep_tab_open = data && data.keep_tab_open === true;
const limit = (() => {
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100;
if (!Number.isFinite(n)) return 100;
return Math.max(1, Math.min(200, Math.floor(n)));
})();
const keyword = category_keyword || 'picnic bag';
const sort_map = {
featured: 'relevanceblender',
review: 'review-rank',
newest: 'date-desc-rank',
price_asc: 'price-asc-rank',
price_desc: 'price-desc-rank',
bestseller: 'exact-aware-popularity-rank',
};
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
.set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
.set_target('__amazon_search_list');
let url = AMAZON_ZH_HOME_URL;
tab_task.open_async()
.then((tab) => {
tab.on_update_complete(async () => {
await tab.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
const home_ok = Array.isArray(home_ret) ? home_ret[0] : home_ret;
if (!home_ok || !home_ok.ok) {
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
}
url = await wait_until_search_list_url(tab.id, 45000);
await tab.wait_complete();
if (sort_s) {
const u = new URL(url);
u.searchParams.set('s', sort_s);
url = u.toString();
await tab.navigate(url);
await tab.wait_complete();
}
const unique_map = new Map();
let next_url = url;
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
if (page > 1) {
await tab.navigate(next_url);
await tab.wait_complete();
}
const injected_result_list = await tab.execute_script(
injected_amazon_search_list,
[{ url: next_url, category_keyword, sort_by }],
'document_idle',
);
const injected_result = Array.isArray(injected_result_list) ? injected_result_list[0] : null;
const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : [];
items.forEach((it) => {
const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null;
if (!k) return;
if (!unique_map.has(k)) unique_map.set(k, it);
});
if (unique_map.size >= limit) break;
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
if (!next_url) break;
}
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) };
const result = ok_response({ tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result });
send_action('amazon_search_list', result);
resolve({ tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result });
if (!keep_tab_open) {
tab.remove(0);
}
}, {
once: true,
on_error: (err) => {
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url || AMAZON_ZH_HOME_URL,
}));
reject(err);
if (!keep_tab_open) {
tab.remove(0);
}
},
});
})
.catch((err) => {
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url || AMAZON_ZH_HOME_URL,
}));
reject(err);
});
});
}
amazon_search_list.desc = 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取';
amazon_search_list.params = {
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
sort_by: { type: 'string', desc: '排序方式featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
limit: { type: 'number', desc: '抓取数量上限(默认 100最大 200', default: 100 },
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false },
const amazon_search_list_action = {
desc: 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取',
params: {
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
sort_by: { type: 'string', desc: '排序方式featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
limit: { type: 'number', desc: '抓取数量上限(默认 100最大 200', default: 100 },
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false },
},
handler: run_amazon_search_list_action,
};
export function amazon_set_language(data, sendResponse) {
return new Promise((resolve, reject) => {
const mapping = {
EN: 'en_US',
ES: 'es_US',
AR: 'ar_AE',
DE: 'de_DE',
HE: 'he_IL',
KO: 'ko_KR',
PT: 'pt_BR',
ZH_CN: 'zh_CN',
ZH_TW: 'zh_TW',
};
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN';
const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
.set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
tab_task.open_async()
.then((tab) => {
tab.on_update_complete(async () => {
// 首次 complete 也会触发:在回调里完成注入与结果采集
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
const inj = Array.isArray(raw) ? raw[0] : raw;
if (!inj || !inj.ok) {
throw new Error((inj && inj.error) || 'switch_language_failed');
}
const final_url = await new Promise((res, rej) => {
chrome.tabs.get(tab.id, (tt) => {
if (chrome.runtime.lastError) return rej(new Error(chrome.runtime.lastError.message));
res(tt && tt.url ? tt.url : '');
});
});
const result = ok_response({ tab_id: tab.id, lang: inj.lang, url: final_url });
send_action('amazon_set_language', result);
resolve({ tab_id: tab.id, lang: inj.lang, url: final_url });
tab.remove(0);
}, {
once: true,
on_error: (err) => {
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: AMAZON_HOME_FOR_LANG,
}));
reject(err);
},
});
})
.catch((err) => {
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: AMAZON_HOME_FOR_LANG,
}));
reject(err);
});
});
}
amazon_set_language.desc =
'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang切换购物界面语言';
amazon_set_language.params = {
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
};
export function amazon_product_detail(data, sendResponse) {
return run_pdp_action(data && data.product_url, injected_amazon_product_detail, [], 'amazon_product_detail', sendResponse);
}
amazon_product_detail.desc =
'Amazon 商品详情标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)';
amazon_product_detail.params = {
product_url: { type: 'string', desc: '商品详情页完整 URL含 /dp/ASIN', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
};
export function amazon_product_reviews(data, sendResponse) {
const limit = data && data.limit != null ? Number(data.limit) : 50;
return run_pdp_action(data && data.product_url, injected_amazon_product_reviews, [{ limit }], 'amazon_product_reviews', sendResponse);
}
amazon_product_reviews.desc = 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)';
amazon_product_reviews.params = {
product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
limit: { type: 'number', desc: '最多条数(默认 50上限 100', default: 50 },
};
export function amazon_product_detail_reviews(data, sendResponse) {
const limit = data && data.limit != null ? Number(data.limit) : 50;
const skip_detail = data && data.skip_detail === true;
const steps = [];
if (!skip_detail) {
steps.push({ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] });
}
steps.push({ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] });
return run_pdp_action_multi(data && data.product_url, steps, 'amazon_product_detail_reviews', sendResponse);
}
function run_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) {
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
return new Promise((resolve, reject) => {
let url = product_url;
Promise.resolve()
.then(() => normalize_product_url(product_url))
.then((normalized_url) => {
url = normalized_url;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
return tab_task.open_async();
})
.then((tab) => {
tab.on_update_complete(async () => {
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle');
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
send_action(action_name, ok_response({ tab_id: tab.id, product_url: url, result }));
resolve({ tab_id: tab.id, product_url: url, result });
tab.remove(0);
}, {
once: true,
on_error: (err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
reject(err);
},
});
})
.catch((err) => {
const is_bad_request = (err && err.message) === '缺少 product_url'
|| (err && err.message) === 'product_url 需为亚马逊域名'
|| (err && err.message) === 'product_url 需包含 /dp/ASIN 或 /gp/product/ASIN';
send_action(action_name, fail_response((err && err.message) || String(err), {
code: is_bad_request ? response_code.bad_request : response_code.runtime_error,
documentURI: is_bad_request ? undefined : url,
}));
reject(err);
});
});
}
function run_pdp_action_multi(product_url, steps, action_name, sendResponse) {
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
return new Promise((resolve, reject) => {
let url = product_url;
Promise.resolve()
.then(() => normalize_product_url(product_url))
.then((normalized_url) => {
url = normalized_url;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
return tab_task.open_async();
})
.then((tab) => {
tab.on_update_complete(async () => {
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const results = {};
for (const step of steps || []) {
if (!step || !step.name || typeof step.injected_fn !== 'function') continue;
const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle');
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
results[step.name] = result;
}
send_action(action_name, ok_response({ tab_id: tab.id, product_url: url, result: results }));
resolve({ tab_id: tab.id, product_url: url, result: results });
tab.remove(0);
}, {
once: true,
on_error: (err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
reject(err);
},
});
})
.catch((err) => {
const is_bad_request = (err && err.message) === '缺少 product_url'
|| (err && err.message) === 'product_url 需为亚马逊域名'
|| (err && err.message) === 'product_url 需包含 /dp/ASIN 或 /gp/product/ASIN';
send_action(action_name, fail_response((err && err.message) || String(err), {
code: is_bad_request ? response_code.bad_request : response_code.runtime_error,
documentURI: is_bad_request ? undefined : url,
}));
reject(err);
});
});
}
amazon_product_detail_reviews.desc = 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true';
amazon_product_detail_reviews.params = {
product_url: { type: 'string', desc: '商品详情页完整 URL含 /dp/ASIN', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
limit: { type: 'number', desc: '最多评论条数(默认 50上限 100', default: 50 },
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
};
export const amazon_actions = [
{
name: 'amazon_search_list',
desc: amazon_search_list_action.desc,
params: amazon_search_list_action.params,
handler: amazon_search_list_action.handler,
},
{
name: 'amazon_set_language',
desc: 'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang切换购物界面语言',
params: {
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
},
handler: run_amazon_set_language_action,
},
{
name: 'amazon_product_detail',
desc: 'Amazon 商品详情标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)',
params: {
product_url: { type: 'string', desc: '商品详情页完整 URL含 /dp/ASIN', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
},
handler: (data, sendResponse) =>
run_amazon_pdp_action(
data && data.product_url,
injected_amazon_product_detail,
[],
'amazon_product_detail',
sendResponse,
),
},
{
name: 'amazon_product_reviews',
desc: 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)',
params: {
product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
limit: { type: 'number', desc: '最多条数(默认 50上限 100', default: 50 },
},
handler: (data, sendResponse) =>
run_amazon_pdp_action(
data && data.product_url,
injected_amazon_product_reviews,
[{ limit: data && data.limit != null ? Number(data.limit) : 50 }],
'amazon_product_reviews',
sendResponse,
),
},
{
name: 'amazon_product_detail_reviews',
desc: 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true',
params: {
product_url: { type: 'string', desc: '商品详情页完整 URL含 /dp/ASIN', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
limit: { type: 'number', desc: '最多评论条数(默认 50上限 100', default: 50 },
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
},
handler: (data, sendResponse) =>
run_amazon_pdp_action_multi(
data && data.product_url,
[
...(data && data.skip_detail === true ? [] : [{ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }]),
{ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit: data && data.limit != null ? Number(data.limit) : 50 }] },
],
'amazon_product_detail_reviews',
sendResponse,
),
},
];

View File

@@ -1,3 +1,5 @@
import { create_tab_task, ok_response, fail_response, guard_sync, response_code } from '../libs/index.js';
// Amazon注入函数 + action 实现amazon.js 仅保留 action 壳)
//
// 约定:
@@ -6,40 +8,15 @@
// ---------- 页面注入(仅依赖页面 DOM ----------
function dispatch_human_click(target_el, options) {
const el = target_el;
if (!el) return false;
options = options && typeof options === 'object' ? options : {};
const pointer_id = Number.isFinite(options.pointer_id) ? options.pointer_id : 1;
const pointer_type = options.pointer_type ? String(options.pointer_type) : 'mouse';
const injected_utils = () => window.__mv2_simple_injected || null;
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { }
try { el.focus && el.focus(); } catch (_) { }
const rect = el.getBoundingClientRect();
const ox = Number.isFinite(options.offset_x) ? options.offset_x : 0;
const oy = Number.isFinite(options.offset_y) ? options.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') {
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 1 }));
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 0 }));
}
} catch (_) { }
el.dispatchEvent(new MouseEvent('mousemove', base));
el.dispatchEvent(new MouseEvent('mouseover', base));
el.dispatchEvent(new MouseEvent('mousedown', base));
el.dispatchEvent(new MouseEvent('mouseup', base));
el.dispatchEvent(new MouseEvent('click', base));
return true;
}
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 || '';
@@ -97,21 +74,13 @@ export function injected_amazon_homepage_search(params) {
const keyword = params && params.keyword ? String(params.keyword).trim() : '';
if (!keyword) return { ok: false, error: 'empty_keyword' };
function 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 (const sel of list) {
const el = document.querySelector(sel);
if (!el) continue;
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return el;
}
const t0 = performance.now();
while (performance.now() - t0 < 40) { }
}
return null;
}
const u = injected_utils();
const wait_query = u && typeof u.wait_query === 'function'
? u.wait_query
: () => null;
const set_input_value = u && typeof u.set_input_value === 'function'
? u.set_input_value
: () => false;
const input = wait_query([
'#twotabsearchtextbox',
@@ -120,10 +89,7 @@ export function injected_amazon_homepage_search(params) {
'input[type="search"][name="field-keywords"]',
], 7000);
if (!input) return { ok: false, error: 'no_search_input' };
input.focus();
input.value = keyword;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
set_input_value(input, keyword);
const btn = wait_query([
'#nav-search-submit-button',
'#nav-search-bar-form input[type="submit"]',
@@ -171,6 +137,7 @@ export function injected_amazon_switch_language(params) {
const code = Object.prototype.hasOwnProperty.call(mapping, raw) ? raw : 'ZH_CN';
const switch_lang = mapping[code];
const href_sel = `a[href="#switch-lang=${switch_lang}"]`;
const u = injected_utils();
const deadline = Date.now() + 6000;
let link = null;
while (Date.now() < deadline) {
@@ -179,8 +146,7 @@ export function injected_amazon_switch_language(params) {
const r = link.getBoundingClientRect();
if (r.width > 0 && r.height > 0) break;
}
const t0 = performance.now();
while (performance.now() - t0 < 40) { }
if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40);
}
if (!link) return { ok: false, error: 'lang_option_timeout', lang: code };
dispatch_human_click(link);
@@ -193,8 +159,7 @@ export function injected_amazon_switch_language(params) {
document.querySelector('input[type="submit"][aria-labelledby*="icp-save-button"]') ||
document.querySelector('span.icp-save-button input[type="submit"]');
if (save) break;
const t1 = performance.now();
while (performance.now() - t1 < 40) { }
if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40);
}
if (save) {
dispatch_human_click(save);
@@ -206,41 +171,17 @@ export function injected_amazon_switch_language(params) {
export function injected_amazon_search_list(params) {
params = params && typeof params === 'object' ? params : {};
const debug = params.debug === true;
const u = injected_utils();
// validateCaptcha在 onUpdated(complete) 钩子里也能自动处理
if ((location.href || '').includes('/errors/validateCaptcha')) {
function dispatch_human_click_local(el) {
if (!el) return false;
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { }
try { el.focus && el.focus(); } catch (_) { }
const rect = el.getBoundingClientRect();
const x = Math.max(1, Math.floor(rect.left + rect.width / 2));
const y = Math.max(1, Math.floor(rect.top + rect.height / 2));
const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
try {
if (typeof PointerEvent === 'function') {
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 1 }));
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 }));
}
} catch (_) { }
el.dispatchEvent(new MouseEvent('mousemove', base));
el.dispatchEvent(new MouseEvent('mouseover', base));
el.dispatchEvent(new MouseEvent('mousedown', base));
el.dispatchEvent(new MouseEvent('mouseup', base));
el.dispatchEvent(new MouseEvent('click', base));
return true;
}
const btn =
document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') ||
document.querySelector('form[action*="validateCaptcha"] input[type="submit"]') ||
document.querySelector('form[action*="validateCaptcha"] button[type="submit"]') ||
document.querySelector('input[type="submit"][value*="Continue"]') ||
document.querySelector('button[type="submit"]');
const clicked = btn ? dispatch_human_click_local(btn) : false;
const clicked = btn ? dispatch_human_click(btn) : false;
if (debug) {
// eslint-disable-next-line no-console
console.log('[amazon][on_complete] validateCaptcha', { clicked, href: location.href });
@@ -252,38 +193,10 @@ export function injected_amazon_search_list(params) {
const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : '';
const sort_by = params && params.sort_by ? String(params.sort_by).trim() : '';
function pick_number(text) {
if (!text) return null;
const s = String(text).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim();
const m = s.match(/(\d+(?:\.\d+)?)/);
return m ? Number(m[1]) : null;
}
function pick_int(text) {
if (!text) return null;
const raw = String(text).replace(/\s+/g, ' ').trim();
const u = raw.toUpperCase().replace(/,/g, '');
const km = u.match(/([\d.]+)\s*K\b/);
if (km) return Math.round(parseFloat(km[1]) * 1000);
const mm = u.match(/([\d.]+)\s*M\b/);
if (mm) return Math.round(parseFloat(mm[1]) * 1000000);
const digits = raw.replace(/[^\d]/g, '');
return digits ? Number(digits) : null;
}
function abs_url(href) {
try {
return new URL(href, location.origin).toString();
} catch (_) {
return href;
}
}
function parse_asin_from_url(url) {
if (!url || typeof url !== 'string') return null;
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;
}
const abs_url = u && typeof u.abs_url === 'function' ? u.abs_url : (x) => x;
const parse_asin_from_url = u && typeof u.parse_asin_from_url === 'function' ? u.parse_asin_from_url : () => null;
const pick_number = u && typeof u.pick_number === 'function' ? u.pick_number : () => null;
const pick_int = u && typeof u.pick_int === 'function' ? u.pick_int : () => null;
function extract_results() {
const items = [];
@@ -367,7 +280,8 @@ export function injected_amazon_search_list(params) {
}
export function injected_amazon_product_detail() {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
const u = injected_utils();
const norm = u && typeof u.norm_space === 'function' ? u.norm_space : (s) => (s || '').replace(/\s+/g, ' ').trim();
const asin_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i);
const asin = asin_match ? asin_match[1].toUpperCase() : null;
@@ -512,3 +426,242 @@ export function wait_until_search_list_url(tab_id, timeout_ms) {
tick();
});
}
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
const AMAZON_HOME_FOR_LANG =
'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN&currency=HKD';
export async function run_amazon_search_list_action(data, sendResponse) {
const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : '';
const sort_by = data && data.sort_by ? String(data.sort_by).trim() : '';
const keep_tab_open = data && data.keep_tab_open === true;
const limit = (() => {
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100;
if (!Number.isFinite(n)) return 100;
return Math.max(1, Math.min(200, Math.floor(n)));
})();
const keyword = category_keyword || 'picnic bag';
const sort_map = {
featured: 'relevanceblender',
review: 'review-rank',
newest: 'date-desc-rank',
price_asc: 'price-asc-rank',
price_desc: 'price-desc-rank',
bestseller: 'exact-aware-popularity-rank',
};
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
.set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
.set_target('__amazon_search_list');
let url = AMAZON_ZH_HOME_URL;
const tab = await tab_task.open_async().catch((err) => {
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url || AMAZON_ZH_HOME_URL,
}));
throw err;
});
const payload = await tab.wait_update_complete_once(async () => {
await tab.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
const home_ok = Array.isArray(home_ret) ? home_ret[0] : home_ret;
if (!home_ok || !home_ok.ok) {
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
}
url = await wait_until_search_list_url(tab.id, 45000);
await tab.wait_complete();
if (sort_s) {
const u = new URL(url);
u.searchParams.set('s', sort_s);
url = u.toString();
await tab.navigate(url);
await tab.wait_complete();
}
const unique_map = new Map();
let next_url = url;
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
if (page > 1) {
await tab.navigate(next_url);
await tab.wait_complete();
}
const injected_result_list = await tab.execute_script(
injected_amazon_search_list,
[{ url: next_url, category_keyword, sort_by }],
'document_idle',
);
const injected_result = Array.isArray(injected_result_list) ? injected_result_list[0] : null;
const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : [];
items.forEach((it) => {
const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null;
if (!k) return;
if (!unique_map.has(k)) unique_map.set(k, it);
});
if (unique_map.size >= limit) break;
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
if (!next_url) break;
}
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) };
return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result };
}).catch((err) => {
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url || AMAZON_ZH_HOME_URL,
}));
if (!keep_tab_open) tab.remove(0);
throw err;
});
send_action('amazon_search_list', ok_response(payload));
if (!keep_tab_open) tab.remove(0);
return payload;
}
export async function run_amazon_set_language_action(data, sendResponse) {
const mapping = {
EN: 'en_US',
ES: 'es_US',
AR: 'ar_AE',
DE: 'de_DE',
HE: 'he_IL',
KO: 'ko_KR',
PT: 'pt_BR',
ZH_CN: 'zh_CN',
ZH_TW: 'zh_TW',
};
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN';
const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
.set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
const tab = await tab_task.open_async().catch((err) => {
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: AMAZON_HOME_FOR_LANG,
}));
throw err;
});
const payload = await tab.wait_update_complete_once(async () => {
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
const inj = Array.isArray(raw) ? raw[0] : raw;
if (!inj || !inj.ok) {
throw new Error((inj && inj.error) || 'switch_language_failed');
}
const final_url = await new Promise((resolve_url, reject_url) => {
chrome.tabs.get(tab.id, (tab_info) => {
if (chrome.runtime.lastError) return reject_url(new Error(chrome.runtime.lastError.message));
resolve_url(tab_info && tab_info.url ? tab_info.url : '');
});
});
return { tab_id: tab.id, lang: inj.lang, url: final_url };
}).catch((err) => {
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: AMAZON_HOME_FOR_LANG,
}));
throw err;
});
send_action('amazon_set_language', ok_response(payload));
tab.remove(0);
return payload;
}
export async function run_amazon_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) {
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const normalized = guard_sync(() => normalize_product_url(product_url));
if (!normalized.ok) {
send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
code: response_code.bad_request,
}));
throw normalized.error;
}
const url = normalized.data;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
const tab = await tab_task.open_async().catch((err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
throw err;
});
const payload = await tab.wait_update_complete_once(async () => {
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle');
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
return { tab_id: tab.id, product_url: url, result };
}).catch((err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
throw err;
});
send_action(action_name, ok_response(payload));
tab.remove(0);
return payload;
}
export async function run_amazon_pdp_action_multi(product_url, steps, action_name, sendResponse) {
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
const normalized = guard_sync(() => normalize_product_url(product_url));
if (!normalized.ok) {
send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
code: response_code.bad_request,
}));
throw normalized.error;
}
const url = normalized.data;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
const tab = await tab_task.open_async().catch((err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
throw err;
});
const payload = await tab.wait_update_complete_once(async () => {
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
await try_solve_amazon_validate_captcha(tab, 3);
const results = {};
for (const step of steps || []) {
if (!step || !step.name || typeof step.injected_fn !== 'function') continue;
const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle');
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
results[step.name] = result;
}
return { tab_id: tab.id, product_url: url, result: results };
}).catch((err) => {
send_action(action_name, fail_response((err && err.message) || String(err), {
code: response_code.runtime_error,
documentURI: url,
}));
throw err;
});
send_action(action_name, ok_response(payload));
tab.remove(0);
return payload;
}

View File

@@ -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;

View File

@@ -1,35 +1,32 @@
import {
amazon_search_list,
amazon_set_language,
amazon_product_detail,
amazon_product_reviews,
amazon_product_detail_reviews,
} from '../actions/amazon.js';
import { amazon_actions, getAllActionsMeta, getActionByName } from '../actions/index.js';
// action 注册表:供 UI 下拉选择 + server bridge 调用
const actions = {
amazon_search_list,
amazon_set_language,
amazon_product_detail,
amazon_product_reviews,
amazon_product_detail_reviews,
// 调试日志系统
const DEBUG = true;
const debug_log = (level, ...args) => {
if (DEBUG) {
const timestamp = new Date().toISOString();
console[level](`[Background ${timestamp}]`, ...args);
}
};
function list_actions_meta() {
const meta = {};
Object.keys(actions).forEach((name) => {
const fn = actions[name];
meta[name] = {
name,
desc: fn && fn.desc ? fn.desc : '',
params: fn && fn.params ? fn.params : {},
};
});
return meta;
// action 注册表:供 UI 下拉选择 + server bridge 调用
let action_list = [];
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 });
};
@@ -37,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,
@@ -79,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();
@@ -98,26 +109,47 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return;
}
const fn = actions[message.action];
if (!fn) {
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 fn(message.data || {}, action_send_response);
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 });
}
})();

View File

@@ -2,6 +2,29 @@
// 目标:页面里触发 XHR/fetch 时派发 __REQUEST_DONE
(() => {
function inject_page_file_once(file_path, marker) {
const root = document.documentElement || document.head;
if (!root) return false;
const mark_key = marker || file_path;
const attr = `data-mv2-injected-${mark_key.replace(/[^a-z0-9_-]/gi, '_')}`;
if (root.hasAttribute(attr)) return true;
const src = chrome.runtime.getURL(file_path);
const el = document.createElement('script');
el.type = 'text/javascript';
el.src = src;
el.onload = () => {
el.parentNode && el.parentNode.removeChild(el);
};
el.onerror = () => {
el.parentNode && el.parentNode.removeChild(el);
root.removeAttribute(attr);
};
root.setAttribute(attr, '1');
(document.head || document.documentElement).appendChild(el);
return true;
}
function inject_inline(fn) {
const el = document.createElement('script');
el.type = 'text/javascript';
@@ -161,5 +184,8 @@
F.__RequestWatcher = true;
}
// 页面上下文通用方法window.__mv2_simple_injected
inject_page_file_once('injected/injected.js', 'core_utils');
inject_inline(request_watcher);
})();

View File

@@ -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
}
};

View File

@@ -0,0 +1,148 @@
(() => {
if (window.__mv2_simple_injected) return;
const norm_space = (s) => (s || '').toString().replace(/\s+/g, ' ').trim();
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) { }
};
const is_visible = (el) => {
if (!el) return false;
const r = el.getBoundingClientRect();
if (!(r.width > 0 && r.height > 0)) return false;
// 尽量避免点击到不可见层display/visibility 由浏览器计算
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;
};
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 (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;
};
const dispatch_human_click = (target_el, options) => {
const el = target_el;
if (!el) return false;
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 (_) { }
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') {
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 1 }));
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 0 }));
}
} catch (_) { }
el.dispatchEvent(new MouseEvent('mousemove', base));
el.dispatchEvent(new MouseEvent('mouseover', base));
el.dispatchEvent(new MouseEvent('mousedown', base));
el.dispatchEvent(new MouseEvent('mouseup', base));
el.dispatchEvent(new MouseEvent('click', base));
return true;
};
const text = (el) => el && el.textContent != null ? norm_space(el.textContent) : null;
const inner_text = (el) => el && el.innerText != null ? norm_space(el.innerText) : null;
const attr = (el, name) => {
if (!el || !name) return null;
const v = el.getAttribute ? el.getAttribute(name) : null;
return v != null ? norm_space(v) : null;
};
const abs_url = (href, base) => {
try {
return new URL(href, base || location.origin).toString();
} catch (_) {
return href;
}
};
const parse_asin_from_url = (url) => {
if (!url || typeof url !== 'string') return null;
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;
};
const pick_number = (text0) => {
if (!text0) return null;
const s = String(text0).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim();
const m = s.match(/(\d+(?:\.\d+)?)/);
return m ? Number(m[1]) : null;
};
const pick_int = (text0) => {
if (!text0) return null;
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);
const mm = up.match(/([\d.]+)\s*M\b/);
if (mm) return Math.round(parseFloat(mm[1]) * 1000000);
const digits = raw.replace(/[^\d]/g, '');
return digits ? Number(digits) : null;
};
const set_input_value = (input, value, options) => {
if (!input) return false;
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) {
try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) { }
}
if (opt.dispatch_change !== false) {
try { input.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) { }
}
return true;
};
window.__mv2_simple_injected = {
norm_space: norm_space,
busy_wait_ms: busy_wait_ms,
wait_query: wait_query,
is_visible: is_visible,
dispatch_human_click: dispatch_human_click,
text: text,
inner_text: inner_text,
attr: attr,
abs_url: abs_url,
parse_asin_from_url: parse_asin_from_url,
pick_number: pick_number,
pick_int: pick_int,
set_input_value: set_input_value,
};
})();

View File

@@ -0,0 +1,13 @@
// 统一绑定 action 元数据:集中配置,同时兼容历史 fn.desc/fn.params 读取方式。
export function bind_action_meta(action_map, meta_map) {
const actions = action_map && typeof action_map === 'object' ? action_map : {};
const metas = meta_map && typeof meta_map === 'object' ? meta_map : {};
Object.keys(metas).forEach((action_name) => {
const action_fn = actions[action_name];
const meta = metas[action_name] || {};
if (typeof action_fn !== 'function') return;
action_fn.desc = meta.desc || '';
action_fn.params = meta.params || {};
});
return metas;
}

View File

@@ -2,6 +2,7 @@ const RESPONSE_CODE_OK = 0;
const RESPONSE_CODE_BAD_REQUEST = 10;
const RESPONSE_CODE_RUNTIME_ERROR = 30;
// 成功响应工厂:统一返回结构与成功码。
export function ok_response(data) {
return {
code: RESPONSE_CODE_OK,
@@ -11,6 +12,7 @@ export function ok_response(data) {
};
}
// 失败响应工厂:统一错误码、错误消息和可选上下文。
export function fail_response(message, options) {
const opts = options && typeof options === 'object' ? options : {};
const code = Number.isFinite(opts.code) ? Number(opts.code) : RESPONSE_CODE_RUNTIME_ERROR;
@@ -25,8 +27,18 @@ export function fail_response(message, options) {
};
}
// 响应码常量:供业务层统一引用,避免魔法数字。
export const response_code = {
ok: RESPONSE_CODE_OK,
bad_request: RESPONSE_CODE_BAD_REQUEST,
runtime_error: RESPONSE_CODE_RUNTIME_ERROR,
};
// 同步执行保护:把同步异常转为统一结果对象,避免业务层到处写 try/catch。
export function guard_sync(task) {
try {
return { ok: true, data: task() };
} catch (error) {
return { ok: false, error };
}
}

View File

@@ -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;

View File

@@ -1,53 +0,0 @@
// executeScriptMV2 使用 chrome.tabs.executeScript
function build_code(fn, args) {
if (typeof fn === 'function') {
if (Array.isArray(args) && args.length) {
return `(${fn.toString()}).apply(null, ${JSON.stringify(args)});`;
}
return `(${fn.toString()})();`;
}
return fn;
}
// execute_script(tabId, fn, args?, runAt?)
export function execute_script(tab_id, fn, args, run_at) {
run_at = run_at || 'document_idle';
const code = build_code(fn, args);
return new Promise((resolve, reject) => {
chrome.tabs.executeScript(
tab_id,
{
code,
runAt: run_at,
},
(result) => {
if (chrome.runtime.lastError) {
return reject(new Error(chrome.runtime.lastError.message));
}
resolve(result);
},
);
});
}
export function inject_file(tab_id, file, run_at) {
run_at = run_at || 'document_idle';
return new Promise((resolve, reject) => {
chrome.tabs.executeScript(
tab_id,
{
file,
runAt: run_at,
},
() => {
if (chrome.runtime.lastError) {
return reject(new Error(chrome.runtime.lastError.message));
}
resolve(true);
},
);
});
}

View File

@@ -1,8 +1,277 @@
// openTabMV2 版本(极简 + 回调风格)
import { execute_script } from './inject.js';
/**
* 构建可执行代码字符串
* @param {Function|string} fn - 要执行的函数或代码字符串
* @param {Array} args - 传递给函数的参数数组
* @returns {string} 可执行的代码字符串
*/
const build_code = (fn, args) => {
if (typeof fn === 'function') {
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 `(${funcStr})();`;
}
if (typeof fn === 'string') {
return fn;
}
throw new TypeError('fn must be a function or string');
};
function update_tab(tab_id, update_props) {
/**
* 在指定标签页中执行原始脚本(低阶接口,不做任何前置注入)
* @param {number} tab_id - 标签页ID
* @param {Function|string} fn - 要执行的函数或代码字符串
* @param {Array} args - 传递给函数的参数数组
* @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle'
* @returns {Promise<Array>} 执行结果数组
*/
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(', ')}`);
}
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 || []);
}
);
});
} 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;
}
}
/**
* 在指定标签页中注入文件
* @param {number} tab_id - 标签页ID
* @param {string} file - 要注入的文件路径(相对于扩展根目录)
* @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle'
* @returns {Promise<boolean>} 注入是否成功
*/
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);
}
);
});
} 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;
}
}
/**
* 从执行结果中提取第一个帧的值
* @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]?.result ?? raw_list[0];
};
/**
* 确保注入脚本已加载到指定标签页
* @param {number} tab_id - 标签页ID
* @param {number} maxRetries - 最大重试次数默认3次
* @returns {Promise<boolean>} 注入是否成功
*/
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');
}
// 检查是否已经注入
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'}`);
}
/**
* 高阶脚本执行接口(默认确保 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<Array>} 执行结果数组
*/
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) {
@@ -11,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) {
@@ -28,6 +297,14 @@ function attach_tab_helpers(tab) {
return await execute_script(tab.id, fn, args, run_at);
};
tab.inject_file = async function inject_file_on_tab(file, run_at) {
return await inject_file(tab.id, file, run_at);
};
tab.ensure_injected = async function ensure_injected_on_tab() {
return await ensure_injected(tab.id);
};
tab.navigate = async function navigate(url, options) {
const nav_options = options && typeof options === 'object' ? options : {};
const active = Object.prototype.hasOwnProperty.call(nav_options, 'active') ? nav_options.active === true : true;
@@ -111,6 +388,16 @@ function attach_tab_helpers(tab) {
return true;
};
// 等待一次 on_update_complete 并返回 worker 结果。
tab.wait_update_complete_once = function wait_update_complete_once(worker) {
return new Promise((resolve, reject) => {
tab.on_update_complete(async () => {
const output = await worker(tab);
resolve(output);
}, { once: true, on_error: reject });
});
};
tab.off_update_complete = function off_update_complete() {
if (!tab._on_update_complete_listener) return;
try {
@@ -132,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<boolean>} 关闭是否成功
*/
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 任务对象:用对象绑定方法,减少重复参数

View File

@@ -5,6 +5,7 @@
"description": "MV2 极简骨架openTab + executeScript + __REQUEST_DONE 监听",
"permissions": [
"tabs",
"storage",
"<all_urls>"
],
"background": {
@@ -20,7 +21,8 @@
}
],
"web_accessible_resources": [
"content/request_watcher.js"
"content/request_watcher.js",
"injected/injected.js"
],
"browser_action": {
"default_title": "mv2_simple_crx"