This commit is contained in:
张成
2026-03-25 15:55:55 +08:00
parent 7ba462aedd
commit 6200fcc66f
9 changed files with 231 additions and 136 deletions

View File

@@ -1,6 +1,8 @@
// Amazon直接在 handler 中编写逻辑,简化封装层级 // Amazon直接在 handler 中编写逻辑,简化封装层级
import { create_tab_task, ok_response, fail_response, guard_sync, response_code, injected_amazon_validate_captcha_continue, injected_amazon_product_detail, injected_amazon_product_reviews, normalize_product_url, pick_first_script_result, run_amazon_pdp_action_multi, injected_amazon_switch_language, injected_amazon_search_list, injected_amazon_homepage_search, sleep_ms, try_solve_amazon_validate_captcha, wait_until_search_list_url, get_tab_url } from './amazon_tool.js';
import { create_tab_task, ok_response, fail_response, guard_sync, response_code, sleep_ms, get_tab_url } from '../libs/index.js';
import { injected_amazon_validate_captcha_continue, injected_amazon_product_detail, injected_amazon_product_reviews, normalize_product_url, pick_first_script_result, run_amazon_pdp_action_multi, injected_amazon_switch_language, injected_amazon_search_list, injected_amazon_homepage_search, try_solve_amazon_validate_captcha, wait_until_search_list_url } from './amazon_tool.js';
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'; 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';
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo'; const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
@@ -17,11 +19,11 @@ export const amazon_actions = [
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' }, category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
sort_by: { type: 'string', desc: '排序方式featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' }, sort_by: { type: 'string', desc: '排序方式featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
limit: { type: 'number', desc: '抓取数量上限(默认 100最大 200', default: 100 }, limit: { type: 'number', desc: '抓取数量上限(默认 100最大 200', default: 100 },
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false }, keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: true },
}, },
handler: async (data, sendResponse) => { handler: async (data, sendResponse) => {
const send_action = create_send_action(sendResponse); const send_action = create_send_action(sendResponse);
const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : ''; 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 sort_by = data && data.sort_by ? String(data.sort_by).trim() : '';
const keep_tab_open = data && data.keep_tab_open === true; const keep_tab_open = data && data.keep_tab_open === true;
@@ -40,27 +42,26 @@ export const amazon_actions = [
bestseller: 'exact-aware-popularity-rank', bestseller: 'exact-aware-popularity-rank',
}; };
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : ''; const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL) const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
.set_latest(false) .set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 }) .set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
.set_target('__amazon_search_list'); .set_target('__amazon_search_list');
let url = AMAZON_ZH_HOME_URL; let url = AMAZON_ZH_HOME_URL;
try { try {
const tab = await tab_task.open_async(); const tab = await tab_task.open_async();
const payload = await tab.wait_update_complete_once(async () => { 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'); const href = await get_tab_url(tab.id).catch(() => '');
await try_solve_amazon_validate_captcha(tab, 3); const is_captcha = String(href).includes('/errors/validateCaptcha');
let home_ok = null; if(is_captcha) {
for (let i = 0; i < 3; i += 1) { await try_solve_amazon_validate_captcha(tab, 3);
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
home_ok = pick_first_script_result(home_ret);
if (home_ok && home_ok.ok) break;
await sleep_ms(400);
await tab.wait_complete();
await try_solve_amazon_validate_captcha(tab, 1);
} }
// 如果在首页搜索页面,则执行首页搜索
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
let home_ok = pick_first_script_result(home_ret);
if (!home_ok || !home_ok.ok) { if (!home_ok || !home_ok.ok) {
const current_url = await get_tab_url(tab.id).catch(() => ''); const current_url = await get_tab_url(tab.id).catch(() => '');
const detail = home_ok && typeof home_ok === 'object' const detail = home_ok && typeof home_ok === 'object'
@@ -103,7 +104,6 @@ export const amazon_actions = [
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) }; 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 }; return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result };
}); });
send_action('amazon_search_list', ok_response(payload)); send_action('amazon_search_list', ok_response(payload));
if (!keep_tab_open) tab.remove(0); if (!keep_tab_open) tab.remove(0);
return payload; return payload;
@@ -125,7 +125,7 @@ export const amazon_actions = [
}, },
handler: async (data, sendResponse) => { handler: async (data, sendResponse) => {
const send_action = create_send_action(sendResponse); const send_action = create_send_action(sendResponse);
const mapping = { const mapping = {
EN: 'en_US', EN: 'en_US',
ES: 'es_US', ES: 'es_US',
@@ -139,11 +139,11 @@ export const amazon_actions = [
}; };
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN'; 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 code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG) const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
.set_latest(false) .set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); .set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
try { try {
const tab = await tab_task.open_async(); const tab = await tab_task.open_async();
const payload = await tab.wait_update_complete_once(async () => { const payload = await tab.wait_update_complete_once(async () => {
@@ -161,7 +161,7 @@ export const amazon_actions = [
}); });
return { tab_id: tab.id, lang: inj.lang, url: final_url }; return { tab_id: tab.id, lang: inj.lang, url: final_url };
}); });
send_action('amazon_set_language', ok_response(payload)); send_action('amazon_set_language', ok_response(payload));
tab.remove(0); tab.remove(0);
return payload; return payload;
@@ -182,7 +182,7 @@ export const amazon_actions = [
}, },
handler: async (data, sendResponse) => { handler: async (data, sendResponse) => {
const send_action = create_send_action(sendResponse); const send_action = create_send_action(sendResponse);
const normalized = guard_sync(() => normalize_product_url(data && data.product_url)); const normalized = guard_sync(() => normalize_product_url(data && data.product_url));
if (!normalized.ok) { if (!normalized.ok) {
send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
@@ -190,10 +190,10 @@ export const amazon_actions = [
})); }));
return; return;
} }
const url = normalized.data; const url = normalized.data;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
try { try {
const tab = await tab_task.open_async(); const tab = await tab_task.open_async();
const payload = await tab.wait_update_complete_once(async () => { const payload = await tab.wait_update_complete_once(async () => {
@@ -202,7 +202,7 @@ export const amazon_actions = [
const result = pick_first_script_result(raw_list); const result = pick_first_script_result(raw_list);
return { tab_id: tab.id, product_url: url, result }; return { tab_id: tab.id, product_url: url, result };
}); });
send_action('amazon_product_detail', ok_response(payload)); send_action('amazon_product_detail', ok_response(payload));
tab.remove(0); tab.remove(0);
return payload; return payload;
@@ -224,7 +224,7 @@ export const amazon_actions = [
}, },
handler: async (data, sendResponse) => { handler: async (data, sendResponse) => {
const send_action = create_send_action(sendResponse); const send_action = create_send_action(sendResponse);
const normalized = guard_sync(() => normalize_product_url(data && data.product_url)); const normalized = guard_sync(() => normalize_product_url(data && data.product_url));
if (!normalized.ok) { if (!normalized.ok) {
send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
@@ -232,10 +232,10 @@ export const amazon_actions = [
})); }));
return; return;
} }
const url = normalized.data; const url = normalized.data;
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
try { try {
const tab = await tab_task.open_async(); const tab = await tab_task.open_async();
const payload = await tab.wait_update_complete_once(async () => { const payload = await tab.wait_update_complete_once(async () => {
@@ -245,7 +245,7 @@ export const amazon_actions = [
const result = pick_first_script_result(raw_list); const result = pick_first_script_result(raw_list);
return { tab_id: tab.id, product_url: url, result }; return { tab_id: tab.id, product_url: url, result };
}); });
send_action('amazon_product_reviews', ok_response(payload)); send_action('amazon_product_reviews', ok_response(payload));
tab.remove(0); tab.remove(0);
return payload; return payload;

View File

@@ -1,4 +1,4 @@
import { create_tab_task, ok_response, fail_response, guard_sync, response_code } from '../libs/index.js'; import { create_tab_task, ok_response, fail_response, guard_sync, response_code, sleep_ms, get_tab_url } from '../libs/index.js';
// Amazon注入函数 + action 实现amazon.js 仅保留 action 壳) // Amazon注入函数 + action 实现amazon.js 仅保留 action 壳)
// //
@@ -7,18 +7,15 @@ import { create_tab_task, ok_response, fail_response, guard_sync, response_code
// - 每个 action 打开 tab 后,通过 tab.set_on_complete_inject 绑定 onUpdated(status=complete) 注入钩子 // - 每个 action 打开 tab 后,通过 tab.set_on_complete_inject 绑定 onUpdated(status=complete) 注入钩子
// ---------- 页面注入(仅依赖页面 DOM ---------- // ---------- 页面注入(仅依赖页面 DOM ----------
// 注意injected_* 会经 tabs.executeScript 序列化执行,闭包外变量不会进入页面,辅助函数只能写在各 injected_* 函数体内。
const injected_utils = () => window.__mv2_simple_injected || null;
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() { export function injected_amazon_validate_captcha_continue() {
const injected_utils = () => window.__mv2_simple_injected || null;
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;
};
const href = location.href || ''; const href = location.href || '';
const is_captcha = href.includes('/errors/validateCaptcha'); const is_captcha = href.includes('/errors/validateCaptcha');
if (!is_captcha) return { ok: true, is_captcha: false, clicked: false, href }; if (!is_captcha) return { ok: true, is_captcha: false, clicked: false, href };
@@ -49,12 +46,7 @@ export function is_amazon_validate_captcha_url(tab_url) {
return tab_url.includes('amazon.') && tab_url.includes('/errors/validateCaptcha'); return tab_url.includes('amazon.') && tab_url.includes('/errors/validateCaptcha');
} }
export function sleep_ms(ms) { export function pick_first_script_result(raw_list) {
const t = Number(ms);
return new Promise((resolve) => setTimeout(resolve, Number.isFinite(t) ? Math.max(0, t) : 0));
}
function pick_first_script_result(raw_list) {
if (!Array.isArray(raw_list) || raw_list.length === 0) return null; if (!Array.isArray(raw_list) || raw_list.length === 0) return null;
const first = raw_list[0]; const first = raw_list[0];
if (first && typeof first === 'object' && Object.prototype.hasOwnProperty.call(first, 'result')) { if (first && typeof first === 'object' && Object.prototype.hasOwnProperty.call(first, 'result')) {
@@ -89,6 +81,12 @@ export async function try_solve_amazon_validate_captcha(tab, max_round) {
} }
export function injected_amazon_homepage_search(params) { export function injected_amazon_homepage_search(params) {
const injected_utils = () => window.__mv2_simple_injected || null;
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;
};
const keyword = params && params.keyword ? String(params.keyword).trim() : ''; const keyword = params && params.keyword ? String(params.keyword).trim() : '';
if (!keyword) return { ok: false, error: 'empty_keyword' }; if (!keyword) return { ok: false, error: 'empty_keyword' };
@@ -156,6 +154,12 @@ export function injected_amazon_homepage_search(params) {
} }
export function injected_amazon_switch_language(params) { export function injected_amazon_switch_language(params) {
const injected_utils = () => window.__mv2_simple_injected || null;
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;
};
const mapping = { const mapping = {
EN: 'en_US', EN: 'en_US',
ES: 'es_US', ES: 'es_US',
@@ -203,6 +207,12 @@ export function injected_amazon_switch_language(params) {
} }
export function injected_amazon_search_list(params) { export function injected_amazon_search_list(params) {
const injected_utils = () => window.__mv2_simple_injected || null;
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;
};
params = params && typeof params === 'object' ? params : {}; params = params && typeof params === 'object' ? params : {};
const debug = params.debug === true; const debug = params.debug === true;
const u = injected_utils(); const u = injected_utils();
@@ -314,7 +324,7 @@ export function injected_amazon_search_list(params) {
} }
export function injected_amazon_product_detail() { export function injected_amazon_product_detail() {
const u = injected_utils(); const u = window.__mv2_simple_injected || null;
const norm = u && typeof u.norm_space === 'function' ? u.norm_space : (s) => (s || '').replace(/\s+/g, ' ').trim(); 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_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i);
const asin = asin_match ? asin_match[1].toUpperCase() : null; const asin = asin_match ? asin_match[1].toUpperCase() : null;
@@ -461,15 +471,6 @@ export function wait_until_search_list_url(tab_id, timeout_ms) {
}); });
} }
async function get_tab_url(tab_id) {
return await new Promise((resolve, reject) => {
chrome.tabs.get(tab_id, (tab) => {
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
resolve(tab && tab.url ? String(tab.url) : '');
});
});
}
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo'; const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
const AMAZON_HOME_FOR_LANG = 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'; 'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN&currency=HKD';

View File

@@ -7,27 +7,6 @@
import { amazon_actions } from './amazon.js'; import { amazon_actions } from './amazon.js';
export { amazon_actions }; export { amazon_actions };
// Amazon 工具函数(仅保留直接需要的)
import { injected_amazon_validate_captcha_continue, is_amazon_validate_captcha_url, injected_amazon_product_detail, injected_amazon_product_reviews, run_amazon_pdp_action_multi } from './amazon_tool.js';
export { injected_amazon_validate_captcha_continue, is_amazon_validate_captcha_url, injected_amazon_product_detail, injected_amazon_product_reviews, run_amazon_pdp_action_multi };
// 便捷的统一导出对象
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() { export function getAllActionsMeta() {
@@ -52,5 +31,8 @@ export function getActionByName(name) {
return amazon_actions.find(item => item && item.name === name); return amazon_actions.find(item => item && item.name === name);
} }
// 默认导出
export default Actions; export default {
amazon: amazon_actions,
};;

View File

@@ -72,6 +72,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return; return;
} }
// 内部page world 执行结果回传
if (message.channel === 'page_exec_bridge') {
return;
}
// content -> background 的推送消息(通用) // content -> background 的推送消息(通用)
if (message.type === 'push') { if (message.type === 'push') {
console.log('Processing push message:', message.action); console.log('Processing push message:', message.action);

View File

@@ -15,6 +15,21 @@ export { raw_execute_script, inject_file, ensure_injected, execute_script, open_
import { bind_action_meta } from './action_meta.js'; import { bind_action_meta } from './action_meta.js';
export { bind_action_meta }; export { bind_action_meta };
// 通用异步工具
export function sleep_ms(ms) {
const t = Number(ms);
return new Promise((resolve) => setTimeout(resolve, Number.isFinite(t) ? Math.max(0, t) : 0));
}
export async function get_tab_url(tab_id) {
return await new Promise((resolve, reject) => {
chrome.tabs.get(tab_id, (tab) => {
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
resolve(tab && tab.url ? String(tab.url) : '');
});
});
}
// 便捷的统一导出对象(可选使用) // 便捷的统一导出对象(可选使用)
export const Libs = { export const Libs = {
// 响应处理 // 响应处理

View File

@@ -27,11 +27,11 @@ const build_code = (fn, args) => {
} }
return `(${funcStr})();`; return `(${funcStr})();`;
} }
if (typeof fn === 'string') { if (typeof fn === 'string') {
return fn; return fn;
} }
throw new TypeError('fn must be a function or string'); throw new TypeError('fn must be a function or string');
}; };
@@ -48,44 +48,127 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
if (!Number.isInteger(tab_id) || tab_id <= 0) { if (!Number.isInteger(tab_id) || tab_id <= 0) {
throw new Error('Invalid tab_id: must be a positive integer'); throw new Error('Invalid tab_id: must be a positive integer');
} }
if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) { if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) {
throw new Error('Invalid fn: must be a function or string'); throw new Error('Invalid fn: must be a function or string');
} }
if (!Array.isArray(args)) { if (!Array.isArray(args)) {
throw new Error('Invalid args: must be an array'); throw new Error('Invalid args: must be an array');
} }
const validRunAt = ['document_start', 'document_end', 'document_idle']; const validRunAt = ['document_start', 'document_end', 'document_idle'];
if (!validRunAt.includes(run_at)) { if (!validRunAt.includes(run_at)) {
throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`);
} }
try { try {
const code = build_code(fn, args); const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`;
const event_name = `__mv2_simple_page_exec_done__${request_id}`;
// 页面上下文执行:用事件把结果从 page world 回传到 extension world
const page_exec_stmt = (() => {
if (typeof fn === 'function') {
// build_code(fn, args) 返回一段“可执行并产生值”的代码(末尾可能带分号)
return `__exec_result = ${build_code(fn, args)}`;
}
// fn 为 string包进 IIFE允许用户在字符串里使用 return 返回值
return `__exec_result = (function () { ${fn} })();`;
})();
const page_script_text = `
(function () {
const __request_id = ${JSON.stringify(request_id)};
const __event_name = ${JSON.stringify(event_name)};
let __exec_result;
Promise.resolve()
.then(() => {
${page_exec_stmt}
return __exec_result;
})
.then((__result) => {
window.dispatchEvent(new CustomEvent(__event_name, {
detail: { request_id: __request_id, ok: true, result: __result }
}));
})
.catch((__err) => {
const __e = __err;
window.dispatchEvent(new CustomEvent(__event_name, {
detail: {
request_id: __request_id,
ok: false,
error: {
message: (__e && __e.message) ? __e.message : String(__e),
stack: (__e && __e.stack) ? __e.stack : ''
}
}
}));
});
})();
`.trim();
const bootstrap_code = `
(function () {
const __request_id = ${JSON.stringify(request_id)};
const __event_name = ${JSON.stringify(event_name)};
const __on_done = (ev) => {
const detail = ev && ev.detail ? ev.detail : null;
if (!detail || detail.request_id !== __request_id) return;
window.removeEventListener(__event_name, __on_done, true);
try {
chrome.runtime.sendMessage({
channel: 'page_exec_bridge',
request_id: __request_id,
ok: !!detail.ok,
result: detail.result,
error_message: detail.error && detail.error.message ? detail.error.message : null,
error_stack: detail.error && detail.error.stack ? detail.error.stack : null
});
} catch (_) {
// ignore
}
};
window.addEventListener(__event_name, __on_done, true);
const el = document.createElement('script');
el.type = 'text/javascript';
el.textContent = ${JSON.stringify(page_script_text)};
(document.head || document.documentElement).appendChild(el);
el.parentNode && el.parentNode.removeChild(el);
})();
`.trim();
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
chrome.runtime.onMessage.removeListener(on_message);
reject(new Error(`Script execution timeout for tab ${tab_id}`)); reject(new Error(`Script execution timeout for tab ${tab_id}`));
}, 30000); // 30秒超时 }, 30000); // 30秒超时
const on_message = (message) => {
if (!message || message.channel !== 'page_exec_bridge' || message.request_id !== request_id) return;
clearTimeout(timeoutId);
chrome.runtime.onMessage.removeListener(on_message);
if (message.ok) return resolve([message.result]);
const err = new Error(message.error_message || 'page script execution failed');
err.stack = message.error_stack || err.stack;
return reject(err);
};
chrome.runtime.onMessage.addListener(on_message);
chrome.tabs.executeScript( chrome.tabs.executeScript(
tab_id, tab_id,
{ {
code, code: bootstrap_code,
runAt: run_at, runAt: run_at,
}, },
(result) => { () => {
clearTimeout(timeoutId);
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
clearTimeout(timeoutId);
chrome.runtime.onMessage.removeListener(on_message);
const error = new Error(chrome.runtime.lastError.message); const error = new Error(chrome.runtime.lastError.message);
error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'EXECUTION_ERROR'; error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'EXECUTION_ERROR';
return reject(error); reject(error);
} }
resolve(result || []);
} }
); );
}); });
@@ -111,16 +194,16 @@ export async function inject_file(tab_id, file, run_at = 'document_idle') {
if (!Number.isInteger(tab_id) || tab_id <= 0) { if (!Number.isInteger(tab_id) || tab_id <= 0) {
throw new Error('Invalid tab_id: must be a positive integer'); throw new Error('Invalid tab_id: must be a positive integer');
} }
if (!file || typeof file !== 'string') { if (!file || typeof file !== 'string') {
throw new Error('Invalid file: must be a non-empty string'); throw new Error('Invalid file: must be a non-empty string');
} }
// 验证文件路径格式 // 验证文件路径格式
if (!file.match(/^[\w\-./]+$/)) { if (!file.match(/^[\w\-./]+$/)) {
throw new Error('Invalid file path: contains invalid characters'); throw new Error('Invalid file path: contains invalid characters');
} }
const validRunAt = ['document_start', 'document_end', 'document_idle']; const validRunAt = ['document_start', 'document_end', 'document_idle'];
if (!validRunAt.includes(run_at)) { if (!validRunAt.includes(run_at)) {
throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`);
@@ -140,15 +223,15 @@ export async function inject_file(tab_id, file, run_at = 'document_idle') {
}, },
(result) => { (result) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
const error = new Error(chrome.runtime.lastError.message); const error = new Error(chrome.runtime.lastError.message);
error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 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'; chrome.runtime.lastError.message?.includes('Cannot access') ? 'FILE_NOT_FOUND' : 'INJECTION_ERROR';
error.file = file; error.file = file;
return reject(error); return reject(error);
} }
resolve(true); resolve(true);
} }
); );
@@ -164,16 +247,6 @@ export async function inject_file(tab_id, file, run_at = 'document_idle') {
} }
} }
/**
* 从执行结果中提取第一个帧的值
* @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} tab_id - 标签页ID
@@ -187,9 +260,8 @@ export async function ensure_injected(tab_id, maxRetries = 3) {
// 检查是否已经注入 // 检查是否已经注入
try { try {
const injected = pick_first_frame_value( const injected_frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle');
await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle') const injected = Array.isArray(injected_frames) && injected_frames.length ? (injected_frames[0]?.result ?? injected_frames[0]) : null;
);
if (injected === true) return true; if (injected === true) return true;
} catch (error) { } catch (error) {
// 如果检查失败,可能是标签页不存在,继续尝试注入 // 如果检查失败,可能是标签页不存在,继续尝试注入
@@ -202,14 +274,13 @@ export async function ensure_injected(tab_id, maxRetries = 3) {
try { try {
// 约定:扩展根目录=src因此 file 使用 src 内相对路径 // 约定:扩展根目录=src因此 file 使用 src 内相对路径
await inject_file(tab_id, 'injected/injected.js', 'document_idle'); await inject_file(tab_id, 'injected/injected.js', 'document_idle');
// 验证注入是否成功 // 验证注入是否成功
const injected = pick_first_frame_value( const injected_frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle');
await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle') const injected = Array.isArray(injected_frames) && injected_frames.length ? (injected_frames[0]?.result ?? injected_frames[0]) : null;
);
if (injected === true) return true; if (injected === true) return true;
// 如果注入后仍然失败,等待一小段时间再重试 // 如果注入后仍然失败,等待一小段时间再重试
if (attempt < maxRetries) { if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 500 * attempt)); await new Promise(resolve => setTimeout(resolve, 500 * attempt));
@@ -351,28 +422,24 @@ const attach_tab_helpers = (tab) => {
let running = false; let running = false;
const once = !!(options && options.once === true); const once = !!(options && options.once === true);
const on_error = options && typeof options.on_error === 'function' ? options.on_error : null;
const listener = async (updated_tab_id, change_info, updated_tab) => { const listener = async (updated_tab_id, change_info, updated_tab) => {
if (updated_tab_id !== tab.id) return; if (updated_tab_id !== tab.id) return;
if (!change_info || change_info.status !== 'complete') return; if (!change_info || change_info.status !== 'complete') return;
if (running) return; if (running) return;
running = true; running = true;
const tab_obj = attach_tab_helpers(updated_tab || tab); const tab_obj = attach_tab_helpers(updated_tab || tab);
try { // try {
await fn(tab_obj, change_info); debugger
if (once) { await fn(tab_obj, change_info);
tab.off_update_complete && tab.off_update_complete(); if (once) {
} tab.off_update_complete && tab.off_update_complete();
} catch (err) {
if (on_error) {
on_error(err, tab_obj, change_info);
} else {
// eslint-disable-next-line no-console
console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) });
}
} finally {
running = false;
} }
// } catch (err) {
// eslint-disable-next-line no-console
// console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) });
// } finally {
running = false;
// }
}; };
chrome.tabs.onUpdated.addListener(listener); chrome.tabs.onUpdated.addListener(listener);
@@ -471,7 +538,7 @@ export async function open_tab(url, options = {}) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
chrome.tabs.onUpdated.removeListener(on_updated); chrome.tabs.onUpdated.removeListener(on_updated);
try { try {
const enhancedTab = attach_tab_helpers(updated_tab); const enhancedTab = attach_tab_helpers(updated_tab);
resolve({ tab_id, tab: enhancedTab }); resolve({ tab_id, tab: enhancedTab });
@@ -560,7 +627,7 @@ export async function close_tab(tab_id, delayOrOptions = {}) {
try { try {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const delay = Math.max(0, opts.delay); const delay = Math.max(0, opts.delay);
if (delay === 0) { if (delay === 0) {
// 立即关闭 // 立即关闭
chrome.tabs.remove(tab_id, () => { chrome.tabs.remove(tab_id, () => {
@@ -656,6 +723,7 @@ export function create_tab_task(url) {
const tab_done = await new Promise((resolve) => { const tab_done = await new Promise((resolve) => {
const on_updated = (tab_id, change_info, tab) => { const on_updated = (tab_id, change_info, tab) => {
if (tab_id !== tab0.id) return; if (tab_id !== tab0.id) return;
if (change_info.status !== 'complete') return; if (change_info.status !== 'complete') return;
chrome.tabs.onUpdated.removeListener(on_updated); chrome.tabs.onUpdated.removeListener(on_updated);

View File

@@ -83,6 +83,22 @@ body {
font-size: 12px; font-size: 12px;
} }
.label_row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
font-size: 12px;
color: var(--text);
line-height: 1.5;
cursor: pointer;
}
.label_row input {
margin-top: 3px;
flex-shrink: 0;
}
.input, .input,
.textarea { .textarea {
width: 100%; width: 100%;

View File

@@ -40,6 +40,8 @@
<div id="action_params_desc" class="hint" style="margin-top:6px; white-space:pre-wrap;"></div> <div id="action_params_desc" class="hint" style="margin-top:6px; white-space:pre-wrap;"></div>
<textarea id="action_params" class="textarea" spellcheck="false">{}</textarea> <textarea id="action_params" class="textarea" spellcheck="false">{}</textarea>
<label class="label_row"><input type="checkbox" id="opt_keep_tab_open" checked /> 执行后保留自动化窗口keep_tab_open覆盖下方 JSON 与本 action 默认值)</label>
<div class="row"> <div class="row">
<button id="btn_run" class="btn primary">执行</button> <button id="btn_run" class="btn primary">执行</button>
<button id="btn_clear" class="btn">清空日志</button> <button id="btn_clear" class="btn">清空日志</button>

View File

@@ -6,6 +6,7 @@ const btn_clear_el = document.getElementById('btn_clear');
const btn_bg_reload_el = document.getElementById('btn_bg_reload'); const btn_bg_reload_el = document.getElementById('btn_bg_reload');
const last_response_el = document.getElementById('last_response'); const last_response_el = document.getElementById('last_response');
const action_log_el = document.getElementById('action_log'); const action_log_el = document.getElementById('action_log');
const opt_keep_tab_open_el = document.getElementById('opt_keep_tab_open');
let actions_meta = {}; let actions_meta = {};
const ui_state = { last_result: null, actions: [] }; const ui_state = { last_result: null, actions: [] };
@@ -66,9 +67,14 @@ const push_action = (obj) => {
render_state(); render_state();
}; };
const apply_keep_tab_open_override = (parsed) => {
if (!opt_keep_tab_open_el || !parsed || typeof parsed !== 'object' || parsed.__parse_error) return parsed;
return { ...parsed, keep_tab_open: opt_keep_tab_open_el.checked === true };
};
btn_run_el.addEventListener('click', () => { btn_run_el.addEventListener('click', () => {
const action = action_name_el.value; const action = action_name_el.value;
const params = safe_json_parse(action_params_el.value || '{}'); const params = apply_keep_tab_open_override(safe_json_parse(action_params_el.value || '{}'));
push_action({ type: 'call', action, params }); push_action({ type: 'call', action, params });
ui_state.last_result = { running: true, action, params }; ui_state.last_result = { running: true, action, params };