1
This commit is contained in:
@@ -1,76 +1,68 @@
|
||||
// Amazon:直接在 handler 中编写逻辑,简化封装层级
|
||||
|
||||
|
||||
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';
|
||||
import {
|
||||
injected_amazon_validate_captcha_continue,
|
||||
injected_amazon_product_detail,
|
||||
injected_amazon_product_reviews,
|
||||
injected_amazon_switch_language,
|
||||
injected_amazon_search_list,
|
||||
injected_amazon_homepage_search,
|
||||
injected_detect_captcha_page,
|
||||
normalize_product_url,
|
||||
pick_first_script_result,
|
||||
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¤cy=HKD';
|
||||
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
|
||||
|
||||
// 公共的 send_action 函数
|
||||
const create_send_action = (sendResponse) => (actionName, response) => {
|
||||
sendResponse({ action: actionName, ...response });
|
||||
// ──────────── 公共工具 ────────────
|
||||
|
||||
const create_send_action = (sendResponse) => (action_name, response) => {
|
||||
sendResponse({ action: action_name, ...response });
|
||||
};
|
||||
|
||||
export const amazon_actions = [
|
||||
{
|
||||
name: 'amazon_search_list',
|
||||
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: true },
|
||||
},
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(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 = {
|
||||
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 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;
|
||||
function parse_limit(data, default_val, max_val) {
|
||||
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : default_val;
|
||||
if (!Number.isFinite(n)) return default_val;
|
||||
return Math.max(1, Math.min(max_val, Math.floor(n)));
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await tab_task.open_async();
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
const href = await get_tab_url(tab.id).catch(() => '');
|
||||
const is_captcha = String(href).includes('/errors/validateCaptcha');
|
||||
if(is_captcha) {
|
||||
// ──────────── 核心业务函数(从 handler 回调中提取) ────────────
|
||||
|
||||
/**
|
||||
* 搜索列表:验证码检测 -> 首页搜索 -> 排序 -> 分页抓取
|
||||
*/
|
||||
async function do_search_list(tab, { keyword, sort_s, category_keyword, sort_by, limit }) {
|
||||
// DOM 检测验证码页
|
||||
const captcha_ret = await tab.execute_script(injected_detect_captcha_page, [], 'document_idle');
|
||||
if (pick_first_script_result(captcha_ret) === true) {
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
}
|
||||
|
||||
// 如果在首页搜索页面,则执行首页搜索
|
||||
|
||||
// 首页搜索
|
||||
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
|
||||
let home_ok = pick_first_script_result(home_ret);
|
||||
const 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'
|
||||
? JSON.stringify(home_ok)
|
||||
: String(home_ok);
|
||||
const detail = home_ok && typeof home_ok === 'object' ? JSON.stringify(home_ok) : String(home_ok);
|
||||
throw new Error(`首页搜索提交失败: ${detail}; url=${current_url || 'unknown'}`);
|
||||
}
|
||||
url = await wait_until_search_list_url(tab.id, 45000);
|
||||
|
||||
// 等待跳转到列表页
|
||||
let 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);
|
||||
@@ -78,6 +70,8 @@ export const amazon_actions = [
|
||||
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) {
|
||||
@@ -85,58 +79,106 @@ export const amazon_actions = [
|
||||
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 = pick_first_script_result(injected_result_list);
|
||||
const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : [];
|
||||
const raw = await tab.execute_script(injected_amazon_search_list, [{ url: next_url, category_keyword, sort_by }], 'document_idle');
|
||||
const result = pick_first_script_result(raw);
|
||||
const items = result && Array.isArray(result.items) ? 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 (k && !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;
|
||||
next_url = result && result.next_url ? String(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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换语言
|
||||
*/
|
||||
async function do_set_language(tab, code) {
|
||||
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 = pick_first_script_result(raw);
|
||||
if (!inj || !inj.ok) {
|
||||
throw new Error((inj && inj.error) || 'switch_language_failed');
|
||||
}
|
||||
const final_url = await get_tab_url(tab.id);
|
||||
return { tab_id: tab.id, lang: inj.lang, url: final_url };
|
||||
}
|
||||
|
||||
/**
|
||||
* PDP 多步骤注入(detail + reviews 等)
|
||||
*/
|
||||
async function do_pdp_steps(tab, url, steps) {
|
||||
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 = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle');
|
||||
results[step.name] = pick_first_script_result(raw);
|
||||
}
|
||||
return { tab_id: tab.id, product_url: url, result: results };
|
||||
}
|
||||
|
||||
// ──────────── Action 定义 ────────────
|
||||
|
||||
export const amazon_actions = [
|
||||
{
|
||||
name: 'amazon_search_list',
|
||||
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: true },
|
||||
},
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(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 = parse_limit(data, 100, 200);
|
||||
const keyword = category_keyword || 'picnic bag';
|
||||
const sort_s = SORT_MAP[sort_by] || '';
|
||||
|
||||
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 tab = null;
|
||||
try {
|
||||
tab = await tab_task.open_async();
|
||||
const payload = await tab.wait_update_complete_once(() =>
|
||||
do_search_list(tab, { keyword, sort_s, category_keyword, sort_by, limit })
|
||||
);
|
||||
send_action('amazon_search_list', ok_response(payload));
|
||||
if (!keep_tab_open) tab.remove(0);
|
||||
return payload;
|
||||
} 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,
|
||||
documentURI: AMAZON_ZH_HOME_URL,
|
||||
}));
|
||||
if (!keep_tab_open) tab.remove(0);
|
||||
if (tab && !keep_tab_open) tab.remove(0);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'amazon_set_language',
|
||||
desc: 'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言',
|
||||
desc: 'Amazon 顶栏语言切换',
|
||||
params: {
|
||||
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
|
||||
},
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(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 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';
|
||||
|
||||
@@ -146,22 +188,7 @@ 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_validate_captcha_continue, [], 'document_idle');
|
||||
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
|
||||
const inj = pick_first_script_result(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 };
|
||||
});
|
||||
|
||||
const payload = await tab.wait_update_complete_once(() => do_set_language(tab, code));
|
||||
send_action('amazon_set_language', ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
@@ -174,90 +201,71 @@ export const amazon_actions = [
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'amazon_product_detail',
|
||||
desc: 'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)',
|
||||
desc: 'Amazon 商品详情(标题、价格、品牌、要点、配送摘要等)',
|
||||
params: {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
},
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(sendResponse);
|
||||
|
||||
const normalized = guard_sync(() => normalize_product_url(data && data.product_url));
|
||||
if (!normalized.ok) {
|
||||
send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
|
||||
code: response_code.bad_request,
|
||||
}));
|
||||
send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request }));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = normalized.data;
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
|
||||
try {
|
||||
const tab = await tab_task.open_async();
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
const raw_list = await tab.execute_script(injected_amazon_product_detail, [], 'document_idle');
|
||||
const result = pick_first_script_result(raw_list);
|
||||
return { tab_id: tab.id, product_url: url, result };
|
||||
});
|
||||
|
||||
const payload = await tab.wait_update_complete_once(() =>
|
||||
do_pdp_steps(tab, url, [{ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }])
|
||||
);
|
||||
send_action('amazon_product_detail', ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
send_action('amazon_product_detail', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
send_action('amazon_product_detail', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url }));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'amazon_product_reviews',
|
||||
desc: 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)',
|
||||
desc: 'Amazon 商品页买家评论',
|
||||
params: {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 },
|
||||
},
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(sendResponse);
|
||||
|
||||
const normalized = guard_sync(() => normalize_product_url(data && data.product_url));
|
||||
if (!normalized.ok) {
|
||||
send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
|
||||
code: response_code.bad_request,
|
||||
}));
|
||||
send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request }));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = normalized.data;
|
||||
const limit = parse_limit(data, 50, 100);
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
|
||||
try {
|
||||
const tab = await tab_task.open_async();
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
const limit = data && data.limit != null ? Number(data.limit) : 50;
|
||||
const raw_list = await tab.execute_script(injected_amazon_product_reviews, [{ limit }], 'document_idle');
|
||||
const result = pick_first_script_result(raw_list);
|
||||
return { tab_id: tab.id, product_url: url, result };
|
||||
});
|
||||
|
||||
const payload = await tab.wait_update_complete_once(() =>
|
||||
do_pdp_steps(tab, url, [{ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] }])
|
||||
);
|
||||
send_action('amazon_product_reviews', ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
send_action('amazon_product_reviews', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
send_action('amazon_product_reviews', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url }));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'amazon_product_detail_reviews',
|
||||
desc: 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)',
|
||||
@@ -266,14 +274,31 @@ export const amazon_actions = [
|
||||
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,
|
||||
[
|
||||
handler: async (data, sendResponse) => {
|
||||
const send_action = create_send_action(sendResponse);
|
||||
const normalized = guard_sync(() => normalize_product_url(data && data.product_url));
|
||||
if (!normalized.ok) {
|
||||
send_action('amazon_product_detail_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request }));
|
||||
return;
|
||||
}
|
||||
const url = normalized.data;
|
||||
const limit = parse_limit(data, 50, 100);
|
||||
const steps = [
|
||||
...(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,
|
||||
),
|
||||
{ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] },
|
||||
];
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
|
||||
try {
|
||||
const tab = await tab_task.open_async();
|
||||
const payload = await tab.wait_update_complete_once(() => do_pdp_steps(tab, url, steps));
|
||||
send_action('amazon_product_detail_reviews', ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
send_action('amazon_product_detail_reviews', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url }));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { create_tab_task, ok_response, fail_response, guard_sync, response_code, sleep_ms, get_tab_url } from '../libs/index.js';
|
||||
import { sleep_ms, get_tab_url } from '../libs/index.js';
|
||||
|
||||
// Amazon:注入函数 + action 实现(amazon.js 仅保留 action 壳)
|
||||
// Amazon:页面注入函数 + 纯工具
|
||||
//
|
||||
// 约定:
|
||||
// - injected_* 在页面上下文执行,只依赖 DOM
|
||||
// - 每个 action 打开 tab 后,通过 tab.set_on_complete_inject 绑定 onUpdated(status=complete) 注入钩子
|
||||
// - 闭包外变量不会进入页面,辅助函数只能写在各 injected_* 函数体内
|
||||
|
||||
// ---------- 页面注入(仅依赖页面 DOM) ----------
|
||||
// 注意: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 dispatch_human_click = (target_el) => {
|
||||
const u = injected_utils();
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options);
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el);
|
||||
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 };
|
||||
|
||||
// 基于 DOM 特征判断验证码页
|
||||
const form = document.querySelector('form[action*="/errors/validateCaptcha"]');
|
||||
if (!form) return { ok: true, is_captcha: false, clicked: false, href: location.href };
|
||||
|
||||
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"]') ||
|
||||
form.querySelector('button[type="submit"].a-button-text') ||
|
||||
form.querySelector('input[type="submit"]') ||
|
||||
form.querySelector('button[type="submit"]') ||
|
||||
document.querySelector('input[type="submit"][value*="Continue"]') ||
|
||||
document.querySelector('button[type="submit"]');
|
||||
|
||||
const clicked = btn ? dispatch_human_click(btn) : false;
|
||||
if (!clicked) {
|
||||
const form = document.querySelector('form[action*="validateCaptcha"]');
|
||||
if (form) {
|
||||
try {
|
||||
form.submit();
|
||||
return { ok: true, is_captcha: true, clicked: true, method: 'submit', href };
|
||||
} catch (_) { }
|
||||
return { ok: true, is_captcha: true, clicked: true, method: 'submit', href: location.href };
|
||||
} catch (_) {
|
||||
return { ok: false, is_captcha: true, clicked: false, method: 'submit', href: location.href };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, is_captcha: true, clicked, method: clicked ? 'dispatch' : 'none', href };
|
||||
return { ok: true, is_captcha: true, clicked, method: clicked ? 'dispatch' : 'none', href: location.href };
|
||||
}
|
||||
|
||||
export function is_amazon_validate_captcha_url(tab_url) {
|
||||
@@ -46,6 +45,36 @@ export function is_amazon_validate_captcha_url(tab_url) {
|
||||
return tab_url.includes('amazon.') && tab_url.includes('/errors/validateCaptcha');
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 DOM 特征检测验证码页(注入到页面执行)
|
||||
*/
|
||||
export function injected_detect_captcha_page() {
|
||||
const form = document.querySelector('form[action*="/errors/validateCaptcha"]');
|
||||
const btn = document.querySelector(
|
||||
'form[action*="/errors/validateCaptcha"] button[type="submit"], form[action*="/errors/validateCaptcha"] input[type="submit"]'
|
||||
);
|
||||
const has_continue_h4 = Array.from(document.querySelectorAll('h4')).some((el) => {
|
||||
const txt = (el.textContent || '').trim().toLowerCase();
|
||||
return txt.includes('continue shopping');
|
||||
});
|
||||
return !!(form && (btn || has_continue_h4));
|
||||
}
|
||||
|
||||
export async function try_solve_amazon_validate_captcha(tab, max_round) {
|
||||
const rounds = Number.isFinite(max_round) ? Math.max(1, Math.min(5, Math.floor(max_round))) : 2;
|
||||
for (let i = 0; i < rounds; i += 1) {
|
||||
const url = await get_tab_url(tab.id).catch(() => '');
|
||||
if (!is_amazon_validate_captcha_url(url)) return true;
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await sleep_ms(800 + Math.floor(Math.random() * 600));
|
||||
await tab.wait_complete();
|
||||
await sleep_ms(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ──────────── 结果工具 ────────────
|
||||
|
||||
export function pick_first_script_result(raw_list) {
|
||||
if (!Array.isArray(raw_list) || raw_list.length === 0) return null;
|
||||
const first = raw_list[0];
|
||||
@@ -55,48 +84,21 @@ export function pick_first_script_result(raw_list) {
|
||||
return first;
|
||||
}
|
||||
|
||||
/** 由 background 传入的 sendResponse 生成统一推送(含可选 .log) */
|
||||
function create_send_action(sendResponse) {
|
||||
return (action, payload) => {
|
||||
if (typeof sendResponse !== 'function') return;
|
||||
sendResponse({ action, data: payload });
|
||||
if (typeof sendResponse.log === 'function') sendResponse.log(payload);
|
||||
};
|
||||
}
|
||||
|
||||
export async function try_solve_amazon_validate_captcha(tab, max_round) {
|
||||
const rounds = Number.isFinite(max_round) ? Math.max(1, Math.min(5, Math.floor(max_round))) : 2;
|
||||
for (let i = 0; i < rounds; i += 1) {
|
||||
const tab_state = await new Promise((resolve) => {
|
||||
chrome.tabs.get(tab.id, (t) => resolve(t || null));
|
||||
});
|
||||
const url = tab_state && tab_state.url ? String(tab_state.url) : '';
|
||||
if (!is_amazon_validate_captcha_url(url)) return true;
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await sleep_ms(800 + Math.floor(Math.random() * 600));
|
||||
await tab.wait_complete();
|
||||
await sleep_ms(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ──────────── 首页搜索(注入) ────────────
|
||||
|
||||
export function injected_amazon_homepage_search(params) {
|
||||
const injected_utils = () => window.__mv2_simple_injected || null;
|
||||
const dispatch_human_click = (target_el, options) => {
|
||||
const dispatch_human_click = (target_el) => {
|
||||
const u = injected_utils();
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options);
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el);
|
||||
return false;
|
||||
};
|
||||
const keyword = params && params.keyword ? String(params.keyword).trim() : '';
|
||||
if (!keyword) return { ok: false, error: 'empty_keyword' };
|
||||
|
||||
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 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',
|
||||
@@ -106,6 +108,7 @@ export function injected_amazon_homepage_search(params) {
|
||||
], 7000);
|
||||
if (!input) return { ok: false, error: 'no_search_input' };
|
||||
set_input_value(input, keyword);
|
||||
|
||||
const btn = wait_query([
|
||||
'#nav-search-submit-button',
|
||||
'#nav-search-bar-form input[type="submit"]',
|
||||
@@ -116,66 +119,47 @@ export function injected_amazon_homepage_search(params) {
|
||||
], 2000);
|
||||
if (btn) {
|
||||
const clicked = dispatch_human_click(btn);
|
||||
if (clicked) {
|
||||
return { ok: true, method: 'button_click' };
|
||||
}
|
||||
if (clicked) return { ok: true, method: 'button_click' };
|
||||
}
|
||||
|
||||
const form = input.form || input.closest('form');
|
||||
if (form && typeof form.requestSubmit === 'function') {
|
||||
try {
|
||||
form.requestSubmit();
|
||||
return { ok: true, method: 'request_submit' };
|
||||
} catch (_) {
|
||||
// continue fallback
|
||||
}
|
||||
try { form.requestSubmit(); return { ok: true, method: 'request_submit' }; } catch (_) {}
|
||||
}
|
||||
if (form && typeof form.submit === 'function') {
|
||||
try {
|
||||
form.submit();
|
||||
return { ok: true, method: 'form_submit' };
|
||||
} catch (_) {
|
||||
// continue fallback
|
||||
}
|
||||
try { form.submit(); return { ok: true, method: 'form_submit' }; } catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
input.focus();
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
||||
// 兜底:直接派发 submit 事件
|
||||
if (form) {
|
||||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
return { ok: true, method: 'keyboard_enter' };
|
||||
} catch (_) {
|
||||
// ignore and return explicit error
|
||||
}
|
||||
} catch (_) {}
|
||||
return { ok: false, error: 'submit_all_fallback_failed', keyword };
|
||||
}
|
||||
|
||||
// ──────────── 切换语言(注入) ────────────
|
||||
|
||||
export function injected_amazon_switch_language(params) {
|
||||
const injected_utils = () => window.__mv2_simple_injected || null;
|
||||
const dispatch_human_click = (target_el, options) => {
|
||||
const dispatch_human_click = (target_el) => {
|
||||
const u = injected_utils();
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options);
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el);
|
||||
return false;
|
||||
};
|
||||
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',
|
||||
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 = params && params.lang != null ? String(params.lang).trim().toUpperCase() : 'ZH_CN';
|
||||
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) {
|
||||
@@ -199,25 +183,25 @@ export function injected_amazon_switch_language(params) {
|
||||
if (save) break;
|
||||
if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40);
|
||||
}
|
||||
if (save) {
|
||||
dispatch_human_click(save);
|
||||
}
|
||||
if (save) dispatch_human_click(save);
|
||||
|
||||
return { ok: true, lang: code };
|
||||
}
|
||||
|
||||
// ──────────── 搜索列表(注入) ────────────
|
||||
|
||||
export function injected_amazon_search_list(params) {
|
||||
const injected_utils = () => window.__mv2_simple_injected || null;
|
||||
const dispatch_human_click = (target_el, options) => {
|
||||
const dispatch_human_click = (target_el) => {
|
||||
const u = injected_utils();
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options);
|
||||
if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el);
|
||||
return false;
|
||||
};
|
||||
params = params && typeof params === 'object' ? params : {};
|
||||
const debug = params.debug === true;
|
||||
const u = injected_utils();
|
||||
|
||||
// validateCaptcha:在 onUpdated(complete) 钩子里也能自动处理
|
||||
// validateCaptcha 页面:直接点击继续
|
||||
if ((location.href || '').includes('/errors/validateCaptcha')) {
|
||||
const btn =
|
||||
document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') ||
|
||||
@@ -226,16 +210,13 @@ export function injected_amazon_search_list(params) {
|
||||
document.querySelector('input[type="submit"][value*="Continue"]') ||
|
||||
document.querySelector('button[type="submit"]');
|
||||
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 });
|
||||
}
|
||||
if (debug) console.log('[amazon][on_complete] validateCaptcha', { clicked, href: location.href });
|
||||
return { stage: 'captcha', href: location.href, clicked };
|
||||
}
|
||||
|
||||
const start_url = params && params.url ? String(params.url) : location.href;
|
||||
const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : '';
|
||||
const sort_by = params && params.sort_by ? String(params.sort_by).trim() : '';
|
||||
const start_url = params.url ? String(params.url) : location.href;
|
||||
const category_keyword = params.category_keyword ? String(params.category_keyword).trim() : '';
|
||||
const sort_by = params.sort_by ? String(params.sort_by).trim() : '';
|
||||
|
||||
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;
|
||||
@@ -284,13 +265,8 @@ export function injected_amazon_search_list(params) {
|
||||
items.push({
|
||||
index: idx + 1,
|
||||
asin: asin || parse_asin_from_url(item_url),
|
||||
title,
|
||||
url: item_url,
|
||||
price,
|
||||
rating,
|
||||
rating_text,
|
||||
review_count,
|
||||
review_count_text,
|
||||
title, url: item_url, price,
|
||||
rating, rating_text, review_count, review_count_text,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
@@ -299,30 +275,23 @@ export function injected_amazon_search_list(params) {
|
||||
function pick_next_url() {
|
||||
const a = document.querySelector('a.s-pagination-next');
|
||||
if (!a) return null;
|
||||
const aria_disabled = (a.getAttribute('aria-disabled') || '').trim().toLowerCase();
|
||||
if (aria_disabled === 'true') return null;
|
||||
if ((a.getAttribute('aria-disabled') || '').trim().toLowerCase() === 'true') return null;
|
||||
if (a.classList && a.classList.contains('s-pagination-disabled')) return null;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href) return null;
|
||||
return abs_url(href);
|
||||
return href ? abs_url(href) : null;
|
||||
}
|
||||
|
||||
const items = extract_results();
|
||||
const out = { start_url, href: location.href, category_keyword, sort_by, total: items.length, items, next_url: pick_next_url() };
|
||||
if (debug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[amazon][on_complete] search_list', {
|
||||
href: out.href,
|
||||
total: out.total,
|
||||
has_next: !!out.next_url,
|
||||
});
|
||||
try {
|
||||
window.__amazon_debug_last_search_list = out;
|
||||
} catch (_) { }
|
||||
console.log('[amazon][on_complete] search_list', { href: out.href, total: out.total, has_next: !!out.next_url });
|
||||
try { window.__amazon_debug_last_search_list = out; } catch (_) {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ──────────── 商品详情(注入) ────────────
|
||||
|
||||
export function injected_amazon_product_detail() {
|
||||
const u = window.__mv2_simple_injected || null;
|
||||
const norm = u && typeof u.norm_space === 'function' ? u.norm_space : (s) => (s || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -331,9 +300,7 @@ export function injected_amazon_product_detail() {
|
||||
|
||||
const product_info = {};
|
||||
function set_info(k, v, max_len) {
|
||||
k = norm(k);
|
||||
v = norm(v);
|
||||
max_len = max_len || 600;
|
||||
k = norm(k); v = norm(v); max_len = max_len || 600;
|
||||
if (!k || !v || k.length > 100) return;
|
||||
if (v.length > max_len) v = v.slice(0, max_len);
|
||||
if (!product_info[k] || v.length > product_info[k].length) product_info[k] = v;
|
||||
@@ -392,25 +359,16 @@ export function injected_amazon_product_detail() {
|
||||
if (del) delivery_hint = norm(del.innerText).slice(0, 500);
|
||||
|
||||
return {
|
||||
stage: 'detail',
|
||||
asin,
|
||||
title,
|
||||
price,
|
||||
brand_line,
|
||||
brand_store_url,
|
||||
rating_stars,
|
||||
review_count_text,
|
||||
ac_badge,
|
||||
social_proof,
|
||||
bestseller_hint,
|
||||
product_info,
|
||||
detail_extra_lines,
|
||||
bullets,
|
||||
delivery_hint,
|
||||
stage: 'detail', asin, title, price,
|
||||
brand_line, brand_store_url, rating_stars, review_count_text,
|
||||
ac_badge, social_proof, bestseller_hint,
|
||||
product_info, detail_extra_lines, bullets, delivery_hint,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────── 商品评论(注入) ────────────
|
||||
|
||||
export function injected_amazon_product_reviews(params) {
|
||||
const raw = params && params.limit != null ? Number(params.limit) : 50;
|
||||
const limit = Number.isFinite(raw) ? Math.max(1, Math.min(100, Math.floor(raw))) : 50;
|
||||
@@ -435,6 +393,8 @@ export function injected_amazon_product_reviews(params) {
|
||||
return { stage: 'reviews', limit, total: items.length, items, url: location.href };
|
||||
}
|
||||
|
||||
// ──────────── URL 工具 ────────────
|
||||
|
||||
export function normalize_product_url(u) {
|
||||
let s = u ? String(u).trim() : '';
|
||||
if (!s) throw new Error('缺少 product_url');
|
||||
@@ -455,248 +415,15 @@ export function is_amazon_search_list_url(tab_url) {
|
||||
return tab_url.includes('k=') || tab_url.includes('keywords=') || tab_url.includes('field-keywords');
|
||||
}
|
||||
|
||||
export function wait_until_search_list_url(tab_id, timeout_ms) {
|
||||
const deadline = Date.now() + (timeout_ms || 45000);
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
chrome.tabs.get(tab_id, (tab) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
const u = tab && tab.url ? tab.url : '';
|
||||
if (is_amazon_search_list_url(u)) return resolve(u);
|
||||
if (Date.now() >= deadline) return reject(new Error('等待首页搜索跳转到列表页超时'));
|
||||
setTimeout(tick, 400);
|
||||
});
|
||||
};
|
||||
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¤cy=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 = create_send_action(sendResponse);
|
||||
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);
|
||||
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;
|
||||
/**
|
||||
* 轮询等待 tab URL 变为搜索列表页(async 循环,替代旧版回调递归)
|
||||
*/
|
||||
export async function wait_until_search_list_url(tab_id, timeout_ms = 45000) {
|
||||
const deadline = Date.now() + timeout_ms;
|
||||
while (Date.now() < deadline) {
|
||||
const url = await get_tab_url(tab_id);
|
||||
if (is_amazon_search_list_url(url)) return url;
|
||||
await sleep_ms(400);
|
||||
await tab.wait_complete();
|
||||
await try_solve_amazon_validate_captcha(tab, 1);
|
||||
}
|
||||
if (!home_ok || !home_ok.ok) {
|
||||
const current_url = await get_tab_url(tab.id).catch(() => '');
|
||||
const detail = home_ok && typeof home_ok === 'object'
|
||||
? JSON.stringify(home_ok)
|
||||
: String(home_ok);
|
||||
throw new Error(`首页搜索提交失败: ${detail}; url=${current_url || 'unknown'}`);
|
||||
}
|
||||
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 = pick_first_script_result(injected_result_list);
|
||||
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 = create_send_action(sendResponse);
|
||||
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 = pick_first_script_result(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 = create_send_action(sendResponse);
|
||||
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 = pick_first_script_result(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 = create_send_action(sendResponse);
|
||||
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 = pick_first_script_result(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;
|
||||
throw new Error('等待首页搜索跳转到列表页超时');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
import { amazon_actions, getAllActionsMeta, getActionByName } from '../actions/index.js';
|
||||
|
||||
// action 注册表:供 UI 下拉选择 + server bridge 调用
|
||||
let action_list = [];
|
||||
// ──────────── Action 注册 ────────────
|
||||
|
||||
let action_list = [];
|
||||
try {
|
||||
if (Array.isArray(amazon_actions)) {
|
||||
action_list = amazon_actions.filter(item => item && typeof item === 'object' && item.name);
|
||||
@@ -15,7 +14,28 @@ try {
|
||||
console.error('Failed to load amazon_actions:', error);
|
||||
}
|
||||
|
||||
const list_actions_meta = () => getAllActionsMeta();
|
||||
// ──────────── UI 事件推送 ────────────
|
||||
|
||||
const ui_page_url = chrome.runtime.getURL('ui/index.html');
|
||||
|
||||
const is_port_closed_error = (message) => {
|
||||
const text = message ? String(message) : '';
|
||||
return text.includes('The message port closed before a response was received');
|
||||
};
|
||||
|
||||
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) {
|
||||
const err_msg = chrome.runtime.lastError.message;
|
||||
if (is_port_closed_error(err_msg)) return;
|
||||
console.warn('Failed to send UI event:', err_msg);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in emit_ui_event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const create_action_send_response = (sender) => {
|
||||
const fn = (payload) => {
|
||||
@@ -27,135 +47,81 @@ const create_action_send_response = (sender) => {
|
||||
return fn;
|
||||
};
|
||||
|
||||
const ui_page_url = chrome.runtime.getURL('ui/index.html');
|
||||
// ──────────── 内置 action 路由 ────────────
|
||||
|
||||
const is_port_closed_error = (message) => {
|
||||
const text = message ? String(message) : '';
|
||||
return text.includes('The message port closed before a response was received');
|
||||
};
|
||||
|
||||
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) {
|
||||
const err_msg = chrome.runtime.lastError.message;
|
||||
// UI 页面未打开/已关闭时属于正常场景,不打印告警噪音
|
||||
if (is_port_closed_error(err_msg)) return;
|
||||
console.warn('Failed to send UI event:', err_msg);
|
||||
}
|
||||
});
|
||||
} 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) => {
|
||||
console.log('Received message:', { message, sender: sender.tab?.url || 'background' });
|
||||
|
||||
if (!message) {
|
||||
console.warn('Empty message received');
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 自己发出来的事件,background 不处理
|
||||
if (message.channel === 'ui_event') {
|
||||
console.log('Ignoring ui_event message');
|
||||
return;
|
||||
}
|
||||
|
||||
// 内部:page world 执行结果回传
|
||||
if (message.channel === 'page_exec_bridge') {
|
||||
return;
|
||||
}
|
||||
|
||||
// content -> background 的推送消息(通用)
|
||||
if (message.type === 'push') {
|
||||
console.log('Processing push message:', message.action);
|
||||
emit_ui_event('push', {
|
||||
type: 'push',
|
||||
action: message.action,
|
||||
data: message.data,
|
||||
sender,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// UI -> background 的 action 调用
|
||||
if (!message.action) {
|
||||
console.error('Missing action in message');
|
||||
sendResponse && sendResponse({ ok: false, error: '缺少 action' });
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 获取 action 元信息(用于下拉/默认参数)
|
||||
if (message.action === 'meta_actions') {
|
||||
const builtin_handlers = {
|
||||
meta_actions(message, sender, sendResponse) {
|
||||
console.log('Returning actions meta');
|
||||
sendResponse({ ok: true, data: list_actions_meta() });
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 刷新后台(重启 background page)
|
||||
if (message.action === 'reload_background') {
|
||||
sendResponse({ ok: true, data: getAllActionsMeta() });
|
||||
},
|
||||
reload_background(message, sender, sendResponse) {
|
||||
console.log('Reloading background page');
|
||||
sendResponse({ ok: true });
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => location.reload(), 50);
|
||||
},
|
||||
};
|
||||
|
||||
const action_item = getActionByName(message.action);
|
||||
const action_handler = action_item && typeof action_item.handler === 'function' ? action_item.handler : null;
|
||||
if (!action_handler) {
|
||||
console.error('Unknown action:', message.action);
|
||||
sendResponse({ ok: false, error: '未知 action: ' + message.action });
|
||||
return;
|
||||
}
|
||||
// ──────────── Action 执行器 ────────────
|
||||
|
||||
async function execute_action(action_handler, message, sender, sendResponse) {
|
||||
const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`;
|
||||
console.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(() => {
|
||||
console.warn(`Action ${message.action} timed out after 30000ms`);
|
||||
emit_ui_event('response', {
|
||||
type: 'response',
|
||||
request_id,
|
||||
ok: false,
|
||||
error: 'Action timed out after 30 seconds',
|
||||
sender
|
||||
});
|
||||
sendResponse({ ok: false, error: 'Action timed out after 30 seconds', request_id });
|
||||
}, 30000); // 30秒超时
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await action_handler(message.data || {}, action_send_response);
|
||||
clearTimeout(timeout);
|
||||
console.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);
|
||||
const stack = (err && err.stack) || '';
|
||||
console.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 });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ──────────── 消息分发 ────────────
|
||||
|
||||
chrome.browserAction.onClicked.addListener(() => {
|
||||
chrome.tabs.create({ url: ui_page_url, active: true });
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// 忽略:空消息、UI 自身事件、page world 桥接
|
||||
if (!message || message.channel === 'ui_event' || message.channel === 'page_exec_bridge') return;
|
||||
|
||||
// content -> background 推送
|
||||
if (message.type === 'push') {
|
||||
console.log('Processing push message:', message.action);
|
||||
emit_ui_event('push', { type: 'push', action: message.action, data: message.data, sender });
|
||||
return;
|
||||
}
|
||||
|
||||
// 缺少 action
|
||||
if (!message.action) {
|
||||
console.error('Missing action in message');
|
||||
sendResponse && sendResponse({ ok: false, error: '缺少 action' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 内置 action(同步处理,不需要 return true)
|
||||
if (builtin_handlers[message.action]) {
|
||||
builtin_handlers[message.action](message, sender, sendResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 业务 action
|
||||
const action_item = getActionByName(message.action);
|
||||
if (!action_item || typeof action_item.handler !== 'function') {
|
||||
console.error('Unknown action:', message.action);
|
||||
sendResponse({ ok: false, error: '未知 action: ' + message.action });
|
||||
return;
|
||||
}
|
||||
|
||||
execute_action(action_item.handler, message, sender, sendResponse);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,80 +1,115 @@
|
||||
// openTab:MV2 版本(极简 + 回调风格)
|
||||
// tabs.js:MV2 Tab 操作工具(Promise 风格)
|
||||
|
||||
// ──────────── Chrome API Promise 封装 ────────────
|
||||
|
||||
function chrome_tabs_get(tab_id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.get(tab_id, (t) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chrome_tabs_update(tab_id, props) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.update(tab_id, props, (t) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(t || true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chrome_tabs_remove(tab_id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.remove(tab_id, () => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chrome_tabs_create(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.create(opts, (t) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chrome_windows_create(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.windows.create(opts, (w) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(w);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chrome_tabs_execute_script(tab_id, details) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.executeScript(tab_id, details, (result) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可执行代码字符串
|
||||
* @param {Function|string} fn - 要执行的函数或代码字符串
|
||||
* @param {Array} args - 传递给函数的参数数组
|
||||
* @returns {string} 可执行的代码字符串
|
||||
* 等待 tab 进入 status=complete(含超时)
|
||||
* 先检查当前状态,已 complete 则直接返回
|
||||
*/
|
||||
function wait_tab_status_complete(tab_id, timeout_ms = 45000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
reject(new Error('等待页面加载超时'));
|
||||
}, timeout_ms);
|
||||
const listener = (id, info, tab) => {
|
||||
if (id !== tab_id || info.status !== 'complete') return;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
clearTimeout(timer);
|
||||
resolve(tab);
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────── 代码构建 ────────────
|
||||
|
||||
const build_code = (fn, args) => {
|
||||
if (typeof fn === 'function') {
|
||||
const funcStr = fn.toString();
|
||||
const func_str = fn.toString();
|
||||
if (Array.isArray(args) && args.length > 0) {
|
||||
// 安全地序列化参数,避免循环引用
|
||||
const serializedArgs = JSON.stringify(args, (key, value) => {
|
||||
const serialized = 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]';
|
||||
}
|
||||
try { JSON.stringify(value); return value; } catch { return '[Object]'; }
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return `(${funcStr}).apply(null, ${serializedArgs});`;
|
||||
return `(${func_str}).apply(null, ${serialized});`;
|
||||
}
|
||||
return `(${funcStr})();`;
|
||||
return `(${func_str})();`;
|
||||
}
|
||||
|
||||
if (typeof fn === 'string') {
|
||||
return fn;
|
||||
}
|
||||
|
||||
if (typeof fn === 'string') return fn;
|
||||
throw new TypeError('fn must be a function or string');
|
||||
};
|
||||
|
||||
// ──────────── 脚本执行(低阶) ────────────
|
||||
|
||||
/**
|
||||
* 在指定标签页中执行原始脚本(低阶接口,不做任何前置注入)
|
||||
* @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>} 执行结果数组
|
||||
* 在页面上下文执行脚本(page world 桥接)
|
||||
* 通过 CustomEvent + chrome.runtime.onMessage 回传结果
|
||||
*/
|
||||
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 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_exec_stmt = typeof fn === 'function'
|
||||
? `__exec_result = ${build_code(fn, args)}`
|
||||
: `__exec_result = (function () { ${fn} })();`;
|
||||
|
||||
const page_script_text = `
|
||||
(function () {
|
||||
@@ -92,14 +127,13 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
|
||||
}));
|
||||
})
|
||||
.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 : ''
|
||||
message: (__err && __err.message) ? __err.message : String(__err),
|
||||
stack: (__err && __err.stack) ? __err.stack : ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -124,9 +158,7 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
|
||||
error_message: detail.error && detail.error.message ? detail.error.message : null,
|
||||
error_stack: detail.error && detail.error.stack ? detail.error.stack : null
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
window.addEventListener(__event_name, __on_done, true);
|
||||
const el = document.createElement('script');
|
||||
@@ -137,15 +169,16 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
// 同时监听 onMessage 回传 + executeScript 报错,无法再简化
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const timeout_id = setTimeout(() => {
|
||||
chrome.runtime.onMessage.removeListener(on_message);
|
||||
reject(new Error(`Script execution timeout for tab ${tab_id}`));
|
||||
}, 30000); // 30秒超时
|
||||
}, 30000);
|
||||
|
||||
const on_message = (message) => {
|
||||
if (!message || message.channel !== 'page_exec_bridge' || message.request_id !== request_id) return;
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeout_id);
|
||||
chrome.runtime.onMessage.removeListener(on_message);
|
||||
if (message.ok) return resolve([message.result]);
|
||||
const err = new Error(message.error_message || 'page script execution failed');
|
||||
@@ -155,203 +188,62 @@ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'docume
|
||||
|
||||
chrome.runtime.onMessage.addListener(on_message);
|
||||
|
||||
chrome.tabs.executeScript(
|
||||
tab_id,
|
||||
{
|
||||
code: bootstrap_code,
|
||||
runAt: run_at,
|
||||
},
|
||||
() => {
|
||||
chrome.tabs.executeScript(tab_id, { code: bootstrap_code, runAt: run_at }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeout_id);
|
||||
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';
|
||||
reject(error);
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
await chrome_tabs_execute_script(tab_id, { file, runAt: run_at });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保注入脚本已加载到指定标签页
|
||||
* @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');
|
||||
}
|
||||
// ──────────── 确保 injected.js 已加载 ────────────
|
||||
|
||||
// 检查是否已经注入
|
||||
export async function ensure_injected(tab_id, max_retries = 3) {
|
||||
// 先检查是否已注入
|
||||
try {
|
||||
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;
|
||||
const frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle');
|
||||
const injected = Array.isArray(frames) && frames.length ? (frames[0]?.result ?? frames[0]) : null;
|
||||
if (injected === true) return true;
|
||||
} catch (error) {
|
||||
// 如果检查失败,可能是标签页不存在,继续尝试注入
|
||||
console.warn(`Failed to check injection status for tab ${tab_id}:`, error.message);
|
||||
} catch (_) {
|
||||
// 检查失败时继续尝试注入
|
||||
}
|
||||
|
||||
// 尝试注入,带重试机制
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
let last_error;
|
||||
for (let i = 1; i <= max_retries; i += 1) {
|
||||
try {
|
||||
// 约定:扩展根目录=src,因此 file 使用 src 内相对路径
|
||||
await inject_file(tab_id, 'injected/injected.js', '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;
|
||||
|
||||
const frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle');
|
||||
const injected = Array.isArray(frames) && frames.length ? (frames[0]?.result ?? frames[0]) : null;
|
||||
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));
|
||||
if (i < max_retries) await new Promise((r) => setTimeout(r, 500 * i));
|
||||
} catch (err) {
|
||||
last_error = err;
|
||||
if (i < max_retries) await new Promise((r) => setTimeout(r, 1000 * i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to ensure injection after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
throw new Error(`注入失败(重试 ${max_retries} 次): ${last_error?.message || 'unknown'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 高阶脚本执行接口(默认确保 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 {
|
||||
// 确保注入(如果需要)
|
||||
const opts = { ensure_injected: true, max_retries: 3, ...options };
|
||||
if (opts.ensure_injected) {
|
||||
await ensure_injected(tab_id, opts.maxRetries);
|
||||
await ensure_injected(tab_id, opts.max_retries);
|
||||
}
|
||||
|
||||
// 执行脚本
|
||||
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) {
|
||||
return reject_update(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
resolve_update(updated_tab || true);
|
||||
});
|
||||
});
|
||||
};
|
||||
// ──────────── Tab 辅助方法绑定 ────────────
|
||||
|
||||
const attach_tab_helpers = (tab) => {
|
||||
if (!tab) return tab;
|
||||
@@ -364,88 +256,45 @@ const attach_tab_helpers = (tab) => {
|
||||
}, Math.max(0, delay_ms));
|
||||
};
|
||||
|
||||
tab.execute_script = async function execute_script_on_tab(fn, args, run_at) {
|
||||
return await execute_script(tab.id, fn, args, run_at);
|
||||
tab.execute_script = (fn, args, run_at) => execute_script(tab.id, fn, args, run_at);
|
||||
tab.inject_file = (file, run_at) => inject_file(tab.id, file, run_at);
|
||||
tab.ensure_injected = () => ensure_injected(tab.id);
|
||||
|
||||
tab.navigate = async (url, options) => {
|
||||
const active = options && options.active === true;
|
||||
return await chrome_tabs_update(tab.id, { url: String(url), active });
|
||||
};
|
||||
|
||||
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;
|
||||
const update_props = { url: String(url), active };
|
||||
return await update_tab(tab.id, update_props);
|
||||
};
|
||||
|
||||
/**
|
||||
* 等待 tab 页面加载完成(status=complete)
|
||||
* - 作为 tab 方法,避免业务层到处传 tab_id
|
||||
*/
|
||||
tab.wait_complete = function wait_complete(timeout_ms) {
|
||||
tab.wait_complete = async function wait_complete(timeout_ms) {
|
||||
const timeout = Number.isFinite(timeout_ms) ? Math.max(0, timeout_ms) : 45000;
|
||||
return new Promise((resolve_wait, reject_wait) => {
|
||||
chrome.tabs.get(tab.id, (tab0) => {
|
||||
if (!chrome.runtime.lastError && tab0 && tab0.status === 'complete') {
|
||||
return resolve_wait(tab0);
|
||||
}
|
||||
const on_updated = (updated_tab_id, change_info, updated_tab) => {
|
||||
if (updated_tab_id !== tab.id) return;
|
||||
if (!change_info || change_info.status !== 'complete') return;
|
||||
chrome.tabs.onUpdated.removeListener(on_updated);
|
||||
resolve_wait(updated_tab || true);
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(on_updated);
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(on_updated);
|
||||
reject_wait(new Error('等待页面加载超时'));
|
||||
}, timeout);
|
||||
});
|
||||
});
|
||||
const t0 = await chrome_tabs_get(tab.id).catch(() => null);
|
||||
if (t0 && t0.status === 'complete') return t0;
|
||||
return await wait_tab_status_complete(tab.id, timeout);
|
||||
};
|
||||
|
||||
/**
|
||||
* 你期望的风格:tab.on_update_complete(() => tab.execute_script(...))
|
||||
* - 每次页面刷新/导航完成(status=complete) 都会触发回调
|
||||
* - 回调里可直接使用 tab.execute_script(而不是外部注入器封装)
|
||||
*/
|
||||
tab._on_update_complete_listener = null;
|
||||
|
||||
tab.on_update_complete = function on_update_complete(fn, options) {
|
||||
if (typeof fn !== 'function') return false;
|
||||
if (!tab.id) return false;
|
||||
if (typeof fn !== 'function' || !tab.id) return false;
|
||||
tab.off_update_complete && tab.off_update_complete();
|
||||
|
||||
let running = false;
|
||||
const once = !!(options && options.once === true);
|
||||
|
||||
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 (updated_tab_id !== tab.id || !change_info || change_info.status !== 'complete') return;
|
||||
if (running) return;
|
||||
running = true;
|
||||
const tab_obj = attach_tab_helpers(updated_tab || tab);
|
||||
// try {
|
||||
debugger
|
||||
await fn(tab_obj, change_info);
|
||||
if (once) {
|
||||
tab.off_update_complete && tab.off_update_complete();
|
||||
}
|
||||
// } 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 {
|
||||
if (once) tab.off_update_complete && tab.off_update_complete();
|
||||
running = false;
|
||||
// }
|
||||
};
|
||||
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
tab._on_update_complete_listener = listener;
|
||||
|
||||
// 注册时如果已 complete,立即触发一次,保证首屏也能执行注入
|
||||
// 注册时如果已 complete,立即触发一次
|
||||
chrome.tabs.get(tab.id, (t0) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (t0 && t0.status === 'complete') {
|
||||
@@ -455,21 +304,21 @@ const 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 });
|
||||
try {
|
||||
resolve(await worker(tab));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
tab.off_update_complete = function off_update_complete() {
|
||||
if (!tab._on_update_complete_listener) return;
|
||||
try {
|
||||
chrome.tabs.onUpdated.removeListener(tab._on_update_complete_listener);
|
||||
} catch (_) { }
|
||||
try { chrome.tabs.onUpdated.removeListener(tab._on_update_complete_listener); } catch (_) {}
|
||||
tab._on_update_complete_listener = null;
|
||||
};
|
||||
|
||||
@@ -484,183 +333,33 @@ const attach_tab_helpers = (tab) => {
|
||||
};
|
||||
|
||||
return tab;
|
||||
}
|
||||
};
|
||||
|
||||
// ──────────── 打开标签页 ────────────
|
||||
|
||||
/**
|
||||
* 打开新标签页并等待加载完成
|
||||
* @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');
|
||||
}
|
||||
|
||||
// 验证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}`));
|
||||
}
|
||||
};
|
||||
|
||||
chrome.tabs.onUpdated.addListener(on_updated);
|
||||
|
||||
// 创建标签页
|
||||
chrome.tabs.create(
|
||||
{
|
||||
const opts = { active: true, timeout: 45000, loadInBackground: false, ...options };
|
||||
const tab = await 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);
|
||||
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;
|
||||
}
|
||||
if (!tab || !tab.id) throw new Error('创建标签页失败');
|
||||
await chrome_tabs_update(tab.id, { url });
|
||||
const done_tab = await wait_tab_status_complete(tab.id, opts.timeout);
|
||||
return { tab_id: tab.id, tab: attach_tab_helpers(done_tab) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定标签页
|
||||
* @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;
|
||||
export async function close_tab(tab_id, delay_ms = 0) {
|
||||
if (delay_ms > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay_ms));
|
||||
}
|
||||
return await chrome_tabs_remove(tab_id);
|
||||
}
|
||||
|
||||
// openTab 任务对象:用对象绑定方法,减少重复参数
|
||||
// ──────────── Tab 任务对象 ────────────
|
||||
|
||||
export function create_tab_task(url) {
|
||||
const task = {
|
||||
url,
|
||||
@@ -671,34 +370,23 @@ export function create_tab_task(url) {
|
||||
height: 900,
|
||||
target: null,
|
||||
active: true,
|
||||
// 你期望的写法:tab_task.on_updated = () => {}
|
||||
on_error: null,
|
||||
on_updated: null,
|
||||
|
||||
set_bounds(bounds) {
|
||||
bounds = bounds && typeof bounds === 'object' ? bounds : {};
|
||||
if (Object.prototype.hasOwnProperty.call(bounds, 'top')) this.top = bounds.top;
|
||||
if (Object.prototype.hasOwnProperty.call(bounds, 'left')) this.left = bounds.left;
|
||||
if (Object.prototype.hasOwnProperty.call(bounds, 'width')) this.width = bounds.width;
|
||||
if (Object.prototype.hasOwnProperty.call(bounds, 'height')) this.height = bounds.height;
|
||||
return this;
|
||||
},
|
||||
set_target(target) {
|
||||
this.target = target || null;
|
||||
return this;
|
||||
},
|
||||
set_latest(latest) {
|
||||
this.latest = !!latest;
|
||||
return this;
|
||||
},
|
||||
set_active(active) {
|
||||
this.active = active !== false;
|
||||
if ('top' in bounds) this.top = bounds.top;
|
||||
if ('left' in bounds) this.left = bounds.left;
|
||||
if ('width' in bounds) this.width = bounds.width;
|
||||
if ('height' in bounds) this.height = bounds.height;
|
||||
return this;
|
||||
},
|
||||
set_target(target) { this.target = target || null; return this; },
|
||||
set_latest(latest) { this.latest = !!latest; return this; },
|
||||
set_active(active) { this.active = active !== false; return this; },
|
||||
|
||||
async open_async() {
|
||||
// 用 chrome.windows.create 新开窗口承载 tab
|
||||
const win = await new Promise((resolve, reject) => {
|
||||
chrome.windows.create(
|
||||
{
|
||||
const win = await chrome_windows_create({
|
||||
url: 'about:blank',
|
||||
type: 'popup',
|
||||
focused: true,
|
||||
@@ -706,35 +394,14 @@ export function create_tab_task(url) {
|
||||
left: this.left,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
},
|
||||
(w) => {
|
||||
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
|
||||
resolve(w);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const tab0 = win && win.tabs && win.tabs[0] ? win.tabs[0] : null;
|
||||
if (!tab0 || !tab0.id) {
|
||||
throw new Error('popup window 创建失败');
|
||||
}
|
||||
if (!tab0 || !tab0.id) throw new Error('popup window 创建失败');
|
||||
|
||||
await update_tab(tab0.id, { url: this.url, active: this.active !== false });
|
||||
|
||||
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);
|
||||
resolve(tab);
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(on_updated);
|
||||
});
|
||||
|
||||
return attach_tab_helpers(tab_done);
|
||||
await chrome_tabs_update(tab0.id, { url: this.url, active: this.active !== false });
|
||||
const done_tab = await wait_tab_status_complete(tab0.id);
|
||||
return attach_tab_helpers(done_tab);
|
||||
},
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ await start_all_cron_tasks();
|
||||
|
||||
app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`server listening on ${port}`);
|
||||
console.log(`[${new Date().toLocaleString()}] server listening on ${port}`);
|
||||
|
||||
@@ -15,11 +15,11 @@ export function get_sequelize_options() {
|
||||
? (sql, timing_ms) => {
|
||||
if (cfg.crawler.log_sql_benchmark === true && typeof timing_ms === 'number') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[sql]', { timing_ms, sql });
|
||||
console.log(`[${new Date().toLocaleString()}] [sql]`, { timing_ms, sql });
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[sql]', sql);
|
||||
console.log(`[${new Date().toLocaleString()}] [sql]`, sql);
|
||||
}
|
||||
: false,
|
||||
define: {
|
||||
|
||||
@@ -2,5 +2,5 @@ import { sequelize } from '../models/index.js';
|
||||
|
||||
await sequelize.sync({ alter: true });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('sync ok');
|
||||
console.log(`[${new Date().toLocaleString()}] sync ok`);
|
||||
await sequelize.close();
|
||||
|
||||
@@ -186,6 +186,8 @@ export async function run_amazon_search_detail_reviews_flow(flow_payload) {
|
||||
|
||||
|
||||
|
||||
await sleep_ms(1000);
|
||||
|
||||
const list_payload = { category_keyword, limit };
|
||||
if (sort_by) {
|
||||
list_payload.sort_by = sort_by;
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function invoke_extension_action(action_name, action_payload, optio
|
||||
const log_enabled = cfg.crawler.log_invoke_action;
|
||||
if (log_enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[invoke_extension_action] start', {
|
||||
console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] start`, {
|
||||
action_name,
|
||||
has_payload: !!action_payload,
|
||||
keys: action_payload && typeof action_payload === 'object' ? Object.keys(action_payload).slice(0, 20) : []
|
||||
@@ -178,14 +178,17 @@ export async function invoke_extension_action(action_name, action_payload, optio
|
||||
|
||||
if (log_enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[invoke_extension_action] ok', { action_name, cost_ms: Date.now() - started_at });
|
||||
console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] ok`, {
|
||||
action_name,
|
||||
cost_ms: Date.now() - started_at
|
||||
});
|
||||
}
|
||||
|
||||
return action_res;
|
||||
} catch (err) {
|
||||
if (log_enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[invoke_extension_action] fail', {
|
||||
console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] fail`, {
|
||||
action_name,
|
||||
cost_ms: Date.now() - started_at,
|
||||
error: (err && err.message) || String(err)
|
||||
|
||||
@@ -43,7 +43,7 @@ async function run_cron_task(task) {
|
||||
async function run_cron_task_with_guard(task_name, task) {
|
||||
if (running_task_name_set.has(task_name)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[cron] skip (already running)', { name: task_name });
|
||||
console.log(`[${new Date().toLocaleString()}] [cron] skip (already running)`, { name: task_name });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ async function run_cron_task_with_guard(task_name, task) {
|
||||
try {
|
||||
await run_cron_task(task);
|
||||
} catch (error) {
|
||||
console.warn('[cron] error', { task_name, error });
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${new Date().toLocaleString()}] [cron] error`, { task_name, error });
|
||||
} finally {
|
||||
running_task_name_set.delete(task_name);
|
||||
}
|
||||
@@ -66,13 +67,14 @@ export async function start_all_cron_tasks() {
|
||||
const job = cron.schedule(task.cron_expression, async () => {
|
||||
await run_cron_task_with_guard(task_name, task);
|
||||
});
|
||||
console.log('job', { task_name, });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${new Date().toLocaleString()}] job`, { task_name });
|
||||
cron_jobs.push(job);
|
||||
|
||||
if (run_now) {
|
||||
// 启动时额外立刻跑一次(仍走 guard,避免与 cron 触发撞车)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[cron] run_now', { task_name });
|
||||
console.log(`[${new Date().toLocaleString()}] [cron] run_now`, { task_name });
|
||||
await run_cron_task_with_guard(task_name, task);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user