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 中编写逻辑,简化封装层级
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_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
@@ -17,7 +19,7 @@ export const amazon_actions = [
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 },
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: true },
},
handler: async (data, sendResponse) => {
const send_action = create_send_action(sendResponse);
@@ -50,17 +52,16 @@ export const amazon_actions = [
try {
const tab = await tab_task.open_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(() => '');
const is_captcha = String(href).includes('/errors/validateCaptcha');
if(is_captcha) {
await try_solve_amazon_validate_captcha(tab, 3);
let home_ok = null;
for (let i = 0; i < 3; i += 1) {
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) {
const current_url = await get_tab_url(tab.id).catch(() => '');
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) };
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));
if (!keep_tab_open) tab.remove(0);
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 壳)
//
@@ -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) 注入钩子
// ---------- 页面注入(仅依赖页面 DOM ----------
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;
};
// 注意injected_* 会经 tabs.executeScript 序列化执行,闭包外变量不会进入页面,辅助函数只能写在各 injected_* 函数体内。
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 is_captcha = href.includes('/errors/validateCaptcha');
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');
}
export function sleep_ms(ms) {
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) {
export function pick_first_script_result(raw_list) {
if (!Array.isArray(raw_list) || raw_list.length === 0) return null;
const first = raw_list[0];
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) {
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() : '';
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) {
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 = {
EN: 'en_US',
ES: 'es_US',
@@ -203,6 +207,12 @@ export function injected_amazon_switch_language(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 : {};
const debug = params.debug === true;
const u = injected_utils();
@@ -314,7 +324,7 @@ export function injected_amazon_search_list(params) {
}
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 asin_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i);
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_HOME_FOR_LANG =
'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';
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() {
@@ -52,5 +31,8 @@ export function getActionByName(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;
}
// 内部page world 执行结果回传
if (message.channel === 'page_exec_bridge') {
return;
}
// content -> background 的推送消息(通用)
if (message.type === 'push') {
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';
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 = {
// 响应处理

View File

@@ -63,29 +63,112 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
}
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) => {
const timeoutId = setTimeout(() => {
chrome.runtime.onMessage.removeListener(on_message);
reject(new Error(`Script execution timeout for tab ${tab_id}`));
}, 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(
tab_id,
{
code,
code: bootstrap_code,
runAt: run_at,
},
(result) => {
clearTimeout(timeoutId);
() => {
if (chrome.runtime.lastError) {
clearTimeout(timeoutId);
chrome.runtime.onMessage.removeListener(on_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';
return reject(error);
reject(error);
}
resolve(result || []);
}
);
});
@@ -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
@@ -187,9 +260,8 @@ export async function ensure_injected(tab_id, maxRetries = 3) {
// 检查是否已经注入
try {
const injected = pick_first_frame_value(
await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle')
);
const injected_frames = 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;
} catch (error) {
// 如果检查失败,可能是标签页不存在,继续尝试注入
@@ -204,9 +276,8 @@ export async function ensure_injected(tab_id, maxRetries = 3) {
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')
);
const injected_frames = 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;
@@ -351,28 +422,24 @@ const attach_tab_helpers = (tab) => {
let running = false;
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) => {
if (updated_tab_id !== tab.id) return;
if (!change_info || change_info.status !== 'complete') return;
if (running) return;
running = true;
const tab_obj = attach_tab_helpers(updated_tab || tab);
try {
// try {
debugger
await fn(tab_obj, change_info);
if (once) {
tab.off_update_complete && tab.off_update_complete();
}
} catch (err) {
if (on_error) {
on_error(err, tab_obj, change_info);
} else {
// } 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 {
// console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) });
// } finally {
running = false;
}
// }
};
chrome.tabs.onUpdated.addListener(listener);
@@ -656,6 +723,7 @@ export function create_tab_task(url) {
const tab_done = await new Promise((resolve) => {
const on_updated = (tab_id, change_info, tab) => {
if (tab_id !== tab0.id) return;
if (change_info.status !== 'complete') return;
chrome.tabs.onUpdated.removeListener(on_updated);

View File

@@ -83,6 +83,22 @@ body {
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,
.textarea {
width: 100%;

View File

@@ -40,6 +40,8 @@
<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>
<label class="label_row"><input type="checkbox" id="opt_keep_tab_open" checked /> 执行后保留自动化窗口keep_tab_open覆盖下方 JSON 与本 action 默认值)</label>
<div class="row">
<button id="btn_run" class="btn primary">执行</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 last_response_el = document.getElementById('last_response');
const action_log_el = document.getElementById('action_log');
const opt_keep_tab_open_el = document.getElementById('opt_keep_tab_open');
let actions_meta = {};
const ui_state = { last_result: null, actions: [] };
@@ -66,9 +67,14 @@ const push_action = (obj) => {
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', () => {
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 });
ui_state.last_result = { running: true, action, params };