1
This commit is contained in:
@@ -1,358 +1,88 @@
|
||||
// Amazon:action(编排逻辑放这里),注入函数放 amazon_tool.js
|
||||
// Amazon:action 壳(编排逻辑移至 amazon_tool.js)
|
||||
|
||||
import { create_tab_task } from '../libs/tabs.js';
|
||||
import { fail_response, ok_response, response_code } from '../libs/action_response.js';
|
||||
import {
|
||||
injected_amazon_homepage_search,
|
||||
injected_amazon_product_detail,
|
||||
injected_amazon_product_reviews,
|
||||
injected_amazon_search_list,
|
||||
injected_amazon_switch_language,
|
||||
injected_amazon_validate_captcha_continue,
|
||||
normalize_product_url,
|
||||
try_solve_amazon_validate_captcha,
|
||||
wait_until_search_list_url,
|
||||
run_amazon_pdp_action,
|
||||
run_amazon_pdp_action_multi,
|
||||
run_amazon_search_list_action,
|
||||
run_amazon_set_language_action,
|
||||
} from './amazon_tool.js';
|
||||
|
||||
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
|
||||
const AMAZON_HOME_FOR_LANG =
|
||||
'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN¤cy=HKD';
|
||||
|
||||
export function amazon_search_list(data, sendResponse) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : '';
|
||||
const sort_by = data && data.sort_by ? String(data.sort_by).trim() : '';
|
||||
const keep_tab_open = data && data.keep_tab_open === true;
|
||||
const limit = (() => {
|
||||
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100;
|
||||
if (!Number.isFinite(n)) return 100;
|
||||
return Math.max(1, Math.min(200, Math.floor(n)));
|
||||
})();
|
||||
const keyword = category_keyword || 'picnic bag';
|
||||
|
||||
const sort_map = {
|
||||
featured: 'relevanceblender',
|
||||
review: 'review-rank',
|
||||
newest: 'date-desc-rank',
|
||||
price_asc: 'price-asc-rank',
|
||||
price_desc: 'price-desc-rank',
|
||||
bestseller: 'exact-aware-popularity-rank',
|
||||
};
|
||||
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
|
||||
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
|
||||
.set_latest(false)
|
||||
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
|
||||
.set_target('__amazon_search_list');
|
||||
|
||||
let url = AMAZON_ZH_HOME_URL;
|
||||
tab_task.open_async()
|
||||
.then((tab) => {
|
||||
tab.on_update_complete(async () => {
|
||||
await tab.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
|
||||
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
|
||||
const home_ok = Array.isArray(home_ret) ? home_ret[0] : home_ret;
|
||||
if (!home_ok || !home_ok.ok) {
|
||||
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
|
||||
}
|
||||
|
||||
url = await wait_until_search_list_url(tab.id, 45000);
|
||||
await tab.wait_complete();
|
||||
|
||||
if (sort_s) {
|
||||
const u = new URL(url);
|
||||
u.searchParams.set('s', sort_s);
|
||||
url = u.toString();
|
||||
await tab.navigate(url);
|
||||
await tab.wait_complete();
|
||||
}
|
||||
|
||||
const unique_map = new Map();
|
||||
let next_url = url;
|
||||
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
|
||||
if (page > 1) {
|
||||
await tab.navigate(next_url);
|
||||
await tab.wait_complete();
|
||||
}
|
||||
const injected_result_list = await tab.execute_script(
|
||||
injected_amazon_search_list,
|
||||
[{ url: next_url, category_keyword, sort_by }],
|
||||
'document_idle',
|
||||
);
|
||||
const injected_result = Array.isArray(injected_result_list) ? injected_result_list[0] : null;
|
||||
const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : [];
|
||||
items.forEach((it) => {
|
||||
const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null;
|
||||
if (!k) return;
|
||||
if (!unique_map.has(k)) unique_map.set(k, it);
|
||||
});
|
||||
if (unique_map.size >= limit) break;
|
||||
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
|
||||
if (!next_url) break;
|
||||
}
|
||||
|
||||
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) };
|
||||
const result = ok_response({ tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result });
|
||||
|
||||
send_action('amazon_search_list', result);
|
||||
resolve({ tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result });
|
||||
if (!keep_tab_open) {
|
||||
tab.remove(0);
|
||||
}
|
||||
}, {
|
||||
once: true,
|
||||
on_error: (err) => {
|
||||
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url || AMAZON_ZH_HOME_URL,
|
||||
}));
|
||||
reject(err);
|
||||
if (!keep_tab_open) {
|
||||
tab.remove(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url || AMAZON_ZH_HOME_URL,
|
||||
}));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
amazon_search_list.desc = 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取';
|
||||
amazon_search_list.params = {
|
||||
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
|
||||
sort_by: { type: 'string', desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
|
||||
limit: { type: 'number', desc: '抓取数量上限(默认 100,最大 200)', default: 100 },
|
||||
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false },
|
||||
const amazon_search_list_action = {
|
||||
desc: 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取',
|
||||
params: {
|
||||
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
|
||||
sort_by: { type: 'string', desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
|
||||
limit: { type: 'number', desc: '抓取数量上限(默认 100,最大 200)', default: 100 },
|
||||
keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false },
|
||||
},
|
||||
handler: run_amazon_search_list_action,
|
||||
};
|
||||
|
||||
export function amazon_set_language(data, sendResponse) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mapping = {
|
||||
EN: 'en_US',
|
||||
ES: 'es_US',
|
||||
AR: 'ar_AE',
|
||||
DE: 'de_DE',
|
||||
HE: 'he_IL',
|
||||
KO: 'ko_KR',
|
||||
PT: 'pt_BR',
|
||||
ZH_CN: 'zh_CN',
|
||||
ZH_TW: 'zh_TW',
|
||||
};
|
||||
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN';
|
||||
const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
|
||||
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
|
||||
.set_latest(false)
|
||||
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
tab_task.open_async()
|
||||
.then((tab) => {
|
||||
tab.on_update_complete(async () => {
|
||||
// 首次 complete 也会触发:在回调里完成注入与结果采集
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
|
||||
const inj = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!inj || !inj.ok) {
|
||||
throw new Error((inj && inj.error) || 'switch_language_failed');
|
||||
}
|
||||
const final_url = await new Promise((res, rej) => {
|
||||
chrome.tabs.get(tab.id, (tt) => {
|
||||
if (chrome.runtime.lastError) return rej(new Error(chrome.runtime.lastError.message));
|
||||
res(tt && tt.url ? tt.url : '');
|
||||
});
|
||||
});
|
||||
const result = ok_response({ tab_id: tab.id, lang: inj.lang, url: final_url });
|
||||
send_action('amazon_set_language', result);
|
||||
resolve({ tab_id: tab.id, lang: inj.lang, url: final_url });
|
||||
tab.remove(0);
|
||||
}, {
|
||||
once: true,
|
||||
on_error: (err) => {
|
||||
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: AMAZON_HOME_FOR_LANG,
|
||||
}));
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: AMAZON_HOME_FOR_LANG,
|
||||
}));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
amazon_set_language.desc =
|
||||
'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言';
|
||||
amazon_set_language.params = {
|
||||
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
|
||||
};
|
||||
|
||||
export function amazon_product_detail(data, sendResponse) {
|
||||
return run_pdp_action(data && data.product_url, injected_amazon_product_detail, [], 'amazon_product_detail', sendResponse);
|
||||
}
|
||||
|
||||
amazon_product_detail.desc =
|
||||
'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)';
|
||||
amazon_product_detail.params = {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
};
|
||||
|
||||
export function amazon_product_reviews(data, sendResponse) {
|
||||
const limit = data && data.limit != null ? Number(data.limit) : 50;
|
||||
return run_pdp_action(data && data.product_url, injected_amazon_product_reviews, [{ limit }], 'amazon_product_reviews', sendResponse);
|
||||
}
|
||||
|
||||
amazon_product_reviews.desc = 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)';
|
||||
amazon_product_reviews.params = {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 },
|
||||
};
|
||||
|
||||
export function amazon_product_detail_reviews(data, sendResponse) {
|
||||
const limit = data && data.limit != null ? Number(data.limit) : 50;
|
||||
const skip_detail = data && data.skip_detail === true;
|
||||
const steps = [];
|
||||
if (!skip_detail) {
|
||||
steps.push({ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] });
|
||||
}
|
||||
steps.push({ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] });
|
||||
return run_pdp_action_multi(data && data.product_url, steps, 'amazon_product_detail_reviews', sendResponse);
|
||||
}
|
||||
|
||||
function run_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) {
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = product_url;
|
||||
Promise.resolve()
|
||||
.then(() => normalize_product_url(product_url))
|
||||
.then((normalized_url) => {
|
||||
url = normalized_url;
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
return tab_task.open_async();
|
||||
})
|
||||
.then((tab) => {
|
||||
tab.on_update_complete(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle');
|
||||
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||
send_action(action_name, ok_response({ tab_id: tab.id, product_url: url, result }));
|
||||
resolve({ tab_id: tab.id, product_url: url, result });
|
||||
tab.remove(0);
|
||||
}, {
|
||||
once: true,
|
||||
on_error: (err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const is_bad_request = (err && err.message) === '缺少 product_url'
|
||||
|| (err && err.message) === 'product_url 需为亚马逊域名'
|
||||
|| (err && err.message) === 'product_url 需包含 /dp/ASIN 或 /gp/product/ASIN';
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: is_bad_request ? response_code.bad_request : response_code.runtime_error,
|
||||
documentURI: is_bad_request ? undefined : url,
|
||||
}));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function run_pdp_action_multi(product_url, steps, action_name, sendResponse) {
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = product_url;
|
||||
Promise.resolve()
|
||||
.then(() => normalize_product_url(product_url))
|
||||
.then((normalized_url) => {
|
||||
url = normalized_url;
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
return tab_task.open_async();
|
||||
})
|
||||
.then((tab) => {
|
||||
tab.on_update_complete(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
|
||||
const results = {};
|
||||
for (const step of steps || []) {
|
||||
if (!step || !step.name || typeof step.injected_fn !== 'function') continue;
|
||||
const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle');
|
||||
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||
results[step.name] = result;
|
||||
}
|
||||
|
||||
send_action(action_name, ok_response({ tab_id: tab.id, product_url: url, result: results }));
|
||||
resolve({ tab_id: tab.id, product_url: url, result: results });
|
||||
tab.remove(0);
|
||||
}, {
|
||||
once: true,
|
||||
on_error: (err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const is_bad_request = (err && err.message) === '缺少 product_url'
|
||||
|| (err && err.message) === 'product_url 需为亚马逊域名'
|
||||
|| (err && err.message) === 'product_url 需包含 /dp/ASIN 或 /gp/product/ASIN';
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: is_bad_request ? response_code.bad_request : response_code.runtime_error,
|
||||
documentURI: is_bad_request ? undefined : url,
|
||||
}));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
amazon_product_detail_reviews.desc = 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)';
|
||||
amazon_product_detail_reviews.params = {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 },
|
||||
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
|
||||
};
|
||||
export const amazon_actions = [
|
||||
{
|
||||
name: 'amazon_search_list',
|
||||
desc: amazon_search_list_action.desc,
|
||||
params: amazon_search_list_action.params,
|
||||
handler: amazon_search_list_action.handler,
|
||||
},
|
||||
{
|
||||
name: 'amazon_set_language',
|
||||
desc: 'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言',
|
||||
params: {
|
||||
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
|
||||
},
|
||||
handler: run_amazon_set_language_action,
|
||||
},
|
||||
{
|
||||
name: 'amazon_product_detail',
|
||||
desc: 'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)',
|
||||
params: {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
},
|
||||
handler: (data, sendResponse) =>
|
||||
run_amazon_pdp_action(
|
||||
data && data.product_url,
|
||||
injected_amazon_product_detail,
|
||||
[],
|
||||
'amazon_product_detail',
|
||||
sendResponse,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'amazon_product_reviews',
|
||||
desc: 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)',
|
||||
params: {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 },
|
||||
},
|
||||
handler: (data, sendResponse) =>
|
||||
run_amazon_pdp_action(
|
||||
data && data.product_url,
|
||||
injected_amazon_product_reviews,
|
||||
[{ limit: data && data.limit != null ? Number(data.limit) : 50 }],
|
||||
'amazon_product_reviews',
|
||||
sendResponse,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'amazon_product_detail_reviews',
|
||||
desc: 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)',
|
||||
params: {
|
||||
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||
limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 },
|
||||
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
|
||||
},
|
||||
handler: (data, sendResponse) =>
|
||||
run_amazon_pdp_action_multi(
|
||||
data && data.product_url,
|
||||
[
|
||||
...(data && data.skip_detail === true ? [] : [{ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }]),
|
||||
{ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit: data && data.limit != null ? Number(data.limit) : 50 }] },
|
||||
],
|
||||
'amazon_product_detail_reviews',
|
||||
sendResponse,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { create_tab_task } from '../libs/tabs.js';
|
||||
import { fail_response, guard_sync, ok_response, response_code } from '../libs/action_response.js';
|
||||
|
||||
// Amazon:注入函数 + action 实现(amazon.js 仅保留 action 壳)
|
||||
//
|
||||
// 约定:
|
||||
@@ -6,39 +9,16 @@
|
||||
|
||||
// ---------- 页面注入(仅依赖页面 DOM) ----------
|
||||
|
||||
function injected_utils() {
|
||||
return window.__mv2_simple_injected || null;
|
||||
}
|
||||
|
||||
function dispatch_human_click(target_el, options) {
|
||||
const el = target_el;
|
||||
if (!el) return false;
|
||||
options = options && typeof options === 'object' ? options : {};
|
||||
const pointer_id = Number.isFinite(options.pointer_id) ? options.pointer_id : 1;
|
||||
const pointer_type = options.pointer_type ? String(options.pointer_type) : 'mouse';
|
||||
|
||||
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { }
|
||||
try { el.focus && el.focus(); } catch (_) { }
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ox = Number.isFinite(options.offset_x) ? options.offset_x : 0;
|
||||
const oy = Number.isFinite(options.offset_y) ? options.offset_y : 0;
|
||||
const x = Math.max(1, Math.floor(rect.left + rect.width / 2 + ox));
|
||||
const y = Math.max(1, Math.floor(rect.top + rect.height / 2 + oy));
|
||||
const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
|
||||
|
||||
try {
|
||||
if (typeof PointerEvent === 'function') {
|
||||
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 1 }));
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 0 }));
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
el.dispatchEvent(new MouseEvent('mousemove', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseover', base));
|
||||
el.dispatchEvent(new MouseEvent('mousedown', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', base));
|
||||
el.dispatchEvent(new MouseEvent('click', base));
|
||||
return true;
|
||||
const u = injected_utils();
|
||||
if (u && typeof u.dispatch_human_click === 'function') {
|
||||
return u.dispatch_human_click(target_el, options);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function injected_amazon_validate_captcha_continue() {
|
||||
@@ -97,21 +77,13 @@ export function injected_amazon_homepage_search(params) {
|
||||
const keyword = params && params.keyword ? String(params.keyword).trim() : '';
|
||||
if (!keyword) return { ok: false, error: 'empty_keyword' };
|
||||
|
||||
function wait_query(selectors, timeout_ms) {
|
||||
const list = Array.isArray(selectors) ? selectors : [];
|
||||
const deadline = Date.now() + (Number.isFinite(timeout_ms) ? timeout_ms : 5000);
|
||||
while (Date.now() < deadline) {
|
||||
for (const sel of list) {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) return el;
|
||||
}
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < 40) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const u = injected_utils();
|
||||
const wait_query = u && typeof u.wait_query === 'function'
|
||||
? u.wait_query
|
||||
: () => null;
|
||||
const set_input_value = u && typeof u.set_input_value === 'function'
|
||||
? u.set_input_value
|
||||
: () => false;
|
||||
|
||||
const input = wait_query([
|
||||
'#twotabsearchtextbox',
|
||||
@@ -120,10 +92,7 @@ export function injected_amazon_homepage_search(params) {
|
||||
'input[type="search"][name="field-keywords"]',
|
||||
], 7000);
|
||||
if (!input) return { ok: false, error: 'no_search_input' };
|
||||
input.focus();
|
||||
input.value = keyword;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
set_input_value(input, keyword);
|
||||
const btn = wait_query([
|
||||
'#nav-search-submit-button',
|
||||
'#nav-search-bar-form input[type="submit"]',
|
||||
@@ -171,6 +140,7 @@ export function injected_amazon_switch_language(params) {
|
||||
const code = Object.prototype.hasOwnProperty.call(mapping, raw) ? raw : 'ZH_CN';
|
||||
const switch_lang = mapping[code];
|
||||
const href_sel = `a[href="#switch-lang=${switch_lang}"]`;
|
||||
const u = injected_utils();
|
||||
const deadline = Date.now() + 6000;
|
||||
let link = null;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -179,8 +149,7 @@ export function injected_amazon_switch_language(params) {
|
||||
const r = link.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) break;
|
||||
}
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < 40) { }
|
||||
if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40);
|
||||
}
|
||||
if (!link) return { ok: false, error: 'lang_option_timeout', lang: code };
|
||||
dispatch_human_click(link);
|
||||
@@ -193,8 +162,7 @@ export function injected_amazon_switch_language(params) {
|
||||
document.querySelector('input[type="submit"][aria-labelledby*="icp-save-button"]') ||
|
||||
document.querySelector('span.icp-save-button input[type="submit"]');
|
||||
if (save) break;
|
||||
const t1 = performance.now();
|
||||
while (performance.now() - t1 < 40) { }
|
||||
if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40);
|
||||
}
|
||||
if (save) {
|
||||
dispatch_human_click(save);
|
||||
@@ -206,41 +174,17 @@ export function injected_amazon_switch_language(params) {
|
||||
export function injected_amazon_search_list(params) {
|
||||
params = params && typeof params === 'object' ? params : {};
|
||||
const debug = params.debug === true;
|
||||
const u = injected_utils();
|
||||
|
||||
// validateCaptcha:在 onUpdated(complete) 钩子里也能自动处理
|
||||
if ((location.href || '').includes('/errors/validateCaptcha')) {
|
||||
function dispatch_human_click_local(el) {
|
||||
if (!el) return false;
|
||||
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { }
|
||||
try { el.focus && el.focus(); } catch (_) { }
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = Math.max(1, Math.floor(rect.left + rect.width / 2));
|
||||
const y = Math.max(1, Math.floor(rect.top + rect.height / 2));
|
||||
const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
|
||||
try {
|
||||
if (typeof PointerEvent === 'function') {
|
||||
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 1 }));
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 }));
|
||||
}
|
||||
} catch (_) { }
|
||||
el.dispatchEvent(new MouseEvent('mousemove', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseover', base));
|
||||
el.dispatchEvent(new MouseEvent('mousedown', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', base));
|
||||
el.dispatchEvent(new MouseEvent('click', base));
|
||||
return true;
|
||||
}
|
||||
|
||||
const btn =
|
||||
document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') ||
|
||||
document.querySelector('form[action*="validateCaptcha"] input[type="submit"]') ||
|
||||
document.querySelector('form[action*="validateCaptcha"] button[type="submit"]') ||
|
||||
document.querySelector('input[type="submit"][value*="Continue"]') ||
|
||||
document.querySelector('button[type="submit"]');
|
||||
const clicked = btn ? dispatch_human_click_local(btn) : false;
|
||||
const clicked = btn ? dispatch_human_click(btn) : false;
|
||||
if (debug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[amazon][on_complete] validateCaptcha', { clicked, href: location.href });
|
||||
@@ -252,38 +196,10 @@ export function injected_amazon_search_list(params) {
|
||||
const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : '';
|
||||
const sort_by = params && params.sort_by ? String(params.sort_by).trim() : '';
|
||||
|
||||
function pick_number(text) {
|
||||
if (!text) return null;
|
||||
const s = String(text).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const m = s.match(/(\d+(?:\.\d+)?)/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
function pick_int(text) {
|
||||
if (!text) return null;
|
||||
const raw = String(text).replace(/\s+/g, ' ').trim();
|
||||
const u = raw.toUpperCase().replace(/,/g, '');
|
||||
const km = u.match(/([\d.]+)\s*K\b/);
|
||||
if (km) return Math.round(parseFloat(km[1]) * 1000);
|
||||
const mm = u.match(/([\d.]+)\s*M\b/);
|
||||
if (mm) return Math.round(parseFloat(mm[1]) * 1000000);
|
||||
const digits = raw.replace(/[^\d]/g, '');
|
||||
return digits ? Number(digits) : null;
|
||||
}
|
||||
|
||||
function abs_url(href) {
|
||||
try {
|
||||
return new URL(href, location.origin).toString();
|
||||
} catch (_) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function parse_asin_from_url(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
const m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
const abs_url = u && typeof u.abs_url === 'function' ? u.abs_url : (x) => x;
|
||||
const parse_asin_from_url = u && typeof u.parse_asin_from_url === 'function' ? u.parse_asin_from_url : () => null;
|
||||
const pick_number = u && typeof u.pick_number === 'function' ? u.pick_number : () => null;
|
||||
const pick_int = u && typeof u.pick_int === 'function' ? u.pick_int : () => null;
|
||||
|
||||
function extract_results() {
|
||||
const items = [];
|
||||
@@ -367,7 +283,8 @@ export function injected_amazon_search_list(params) {
|
||||
}
|
||||
|
||||
export function injected_amazon_product_detail() {
|
||||
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
||||
const u = injected_utils();
|
||||
const norm = u && typeof u.norm_space === 'function' ? u.norm_space : (s) => (s || '').replace(/\s+/g, ' ').trim();
|
||||
const asin_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i);
|
||||
const asin = asin_match ? asin_match[1].toUpperCase() : null;
|
||||
|
||||
@@ -512,3 +429,242 @@ export function wait_until_search_list_url(tab_id, timeout_ms) {
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
|
||||
const AMAZON_HOME_FOR_LANG =
|
||||
'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN¤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 = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
|
||||
.set_latest(false)
|
||||
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
|
||||
.set_target('__amazon_search_list');
|
||||
let url = AMAZON_ZH_HOME_URL;
|
||||
const tab = await tab_task.open_async().catch((err) => {
|
||||
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url || AMAZON_ZH_HOME_URL,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
|
||||
const home_ok = Array.isArray(home_ret) ? home_ret[0] : home_ret;
|
||||
if (!home_ok || !home_ok.ok) {
|
||||
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
|
||||
}
|
||||
url = await wait_until_search_list_url(tab.id, 45000);
|
||||
await tab.wait_complete();
|
||||
if (sort_s) {
|
||||
const u = new URL(url);
|
||||
u.searchParams.set('s', sort_s);
|
||||
url = u.toString();
|
||||
await tab.navigate(url);
|
||||
await tab.wait_complete();
|
||||
}
|
||||
const unique_map = new Map();
|
||||
let next_url = url;
|
||||
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
|
||||
if (page > 1) {
|
||||
await tab.navigate(next_url);
|
||||
await tab.wait_complete();
|
||||
}
|
||||
const injected_result_list = await tab.execute_script(
|
||||
injected_amazon_search_list,
|
||||
[{ url: next_url, category_keyword, sort_by }],
|
||||
'document_idle',
|
||||
);
|
||||
const injected_result = Array.isArray(injected_result_list) ? injected_result_list[0] : null;
|
||||
const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : [];
|
||||
items.forEach((it) => {
|
||||
const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null;
|
||||
if (!k) return;
|
||||
if (!unique_map.has(k)) unique_map.set(k, it);
|
||||
});
|
||||
if (unique_map.size >= limit) break;
|
||||
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
|
||||
if (!next_url) break;
|
||||
}
|
||||
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) };
|
||||
return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result };
|
||||
}).catch((err) => {
|
||||
send_action('amazon_search_list', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url || AMAZON_ZH_HOME_URL,
|
||||
}));
|
||||
if (!keep_tab_open) tab.remove(0);
|
||||
throw err;
|
||||
});
|
||||
send_action('amazon_search_list', ok_response(payload));
|
||||
if (!keep_tab_open) tab.remove(0);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function run_amazon_set_language_action(data, sendResponse) {
|
||||
const mapping = {
|
||||
EN: 'en_US',
|
||||
ES: 'es_US',
|
||||
AR: 'ar_AE',
|
||||
DE: 'de_DE',
|
||||
HE: 'he_IL',
|
||||
KO: 'ko_KR',
|
||||
PT: 'pt_BR',
|
||||
ZH_CN: 'zh_CN',
|
||||
ZH_TW: 'zh_TW',
|
||||
};
|
||||
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN';
|
||||
const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
|
||||
.set_latest(false)
|
||||
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
const tab = await tab_task.open_async().catch((err) => {
|
||||
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: AMAZON_HOME_FOR_LANG,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
|
||||
const inj = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!inj || !inj.ok) {
|
||||
throw new Error((inj && inj.error) || 'switch_language_failed');
|
||||
}
|
||||
const final_url = await new Promise((resolve_url, reject_url) => {
|
||||
chrome.tabs.get(tab.id, (tab_info) => {
|
||||
if (chrome.runtime.lastError) return reject_url(new Error(chrome.runtime.lastError.message));
|
||||
resolve_url(tab_info && tab_info.url ? tab_info.url : '');
|
||||
});
|
||||
});
|
||||
return { tab_id: tab.id, lang: inj.lang, url: final_url };
|
||||
}).catch((err) => {
|
||||
send_action('amazon_set_language', fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: AMAZON_HOME_FOR_LANG,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
send_action('amazon_set_language', ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function run_amazon_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) {
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
const normalized = guard_sync(() => normalize_product_url(product_url));
|
||||
if (!normalized.ok) {
|
||||
send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
|
||||
code: response_code.bad_request,
|
||||
}));
|
||||
throw normalized.error;
|
||||
}
|
||||
const url = normalized.data;
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
const tab = await tab_task.open_async().catch((err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle');
|
||||
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||
return { tab_id: tab.id, product_url: url, result };
|
||||
}).catch((err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
send_action(action_name, ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function run_amazon_pdp_action_multi(product_url, steps, action_name, sendResponse) {
|
||||
const send_action = (action, payload) => {
|
||||
if (typeof sendResponse === 'function') {
|
||||
sendResponse({ action, data: payload });
|
||||
sendResponse.log && sendResponse.log(payload);
|
||||
}
|
||||
};
|
||||
const normalized = guard_sync(() => normalize_product_url(product_url));
|
||||
if (!normalized.ok) {
|
||||
send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), {
|
||||
code: response_code.bad_request,
|
||||
}));
|
||||
throw normalized.error;
|
||||
}
|
||||
const url = normalized.data;
|
||||
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||
const tab = await tab_task.open_async().catch((err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
const payload = await tab.wait_update_complete_once(async () => {
|
||||
await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle');
|
||||
await try_solve_amazon_validate_captcha(tab, 3);
|
||||
const results = {};
|
||||
for (const step of steps || []) {
|
||||
if (!step || !step.name || typeof step.injected_fn !== 'function') continue;
|
||||
const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle');
|
||||
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||
results[step.name] = result;
|
||||
}
|
||||
return { tab_id: tab.id, product_url: url, result: results };
|
||||
}).catch((err) => {
|
||||
send_action(action_name, fail_response((err && err.message) || String(err), {
|
||||
code: response_code.runtime_error,
|
||||
documentURI: url,
|
||||
}));
|
||||
throw err;
|
||||
});
|
||||
send_action(action_name, ok_response(payload));
|
||||
tab.remove(0);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
|
||||
import {
|
||||
amazon_search_list,
|
||||
amazon_set_language,
|
||||
amazon_product_detail,
|
||||
amazon_product_reviews,
|
||||
amazon_product_detail_reviews,
|
||||
} from '../actions/amazon.js';
|
||||
import { amazon_actions } from '../actions/amazon.js';
|
||||
|
||||
// action 注册表:供 UI 下拉选择 + server bridge 调用
|
||||
const actions = {
|
||||
amazon_search_list,
|
||||
amazon_set_language,
|
||||
amazon_product_detail,
|
||||
amazon_product_reviews,
|
||||
amazon_product_detail_reviews,
|
||||
};
|
||||
const action_list = Array.isArray(amazon_actions) ? amazon_actions : [];
|
||||
|
||||
function list_actions_meta() {
|
||||
const meta = {};
|
||||
Object.keys(actions).forEach((name) => {
|
||||
const fn = actions[name];
|
||||
meta[name] = {
|
||||
name,
|
||||
desc: fn && fn.desc ? fn.desc : '',
|
||||
params: fn && fn.params ? fn.params : {},
|
||||
action_list.forEach((item) => {
|
||||
if (!item || !item.name) return;
|
||||
meta[item.name] = {
|
||||
name: item.name,
|
||||
desc: item.desc || '',
|
||||
params: item.params || {},
|
||||
};
|
||||
});
|
||||
return meta;
|
||||
@@ -98,8 +86,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = actions[message.action];
|
||||
if (!fn) {
|
||||
const action_item = action_list.find((item) => item && item.name === message.action);
|
||||
const action_handler = action_item && typeof action_item.handler === 'function' ? action_item.handler : null;
|
||||
if (!action_handler) {
|
||||
sendResponse({ ok: false, error: '未知 action: ' + message.action });
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +100,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fn(message.data || {}, action_send_response);
|
||||
const res = await action_handler(message.data || {}, action_send_response);
|
||||
emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender });
|
||||
sendResponse({ ok: true, data: res, request_id });
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
// 目标:页面里触发 XHR/fetch 时派发 __REQUEST_DONE
|
||||
|
||||
(() => {
|
||||
function inject_page_file_once(file_path, marker) {
|
||||
const root = document.documentElement || document.head;
|
||||
if (!root) return false;
|
||||
const mark_key = marker || file_path;
|
||||
const attr = `data-mv2-injected-${mark_key.replace(/[^a-z0-9_-]/gi, '_')}`;
|
||||
if (root.hasAttribute(attr)) return true;
|
||||
|
||||
const src = chrome.runtime.getURL(file_path);
|
||||
const el = document.createElement('script');
|
||||
el.type = 'text/javascript';
|
||||
el.src = src;
|
||||
el.onload = () => {
|
||||
el.parentNode && el.parentNode.removeChild(el);
|
||||
};
|
||||
el.onerror = () => {
|
||||
el.parentNode && el.parentNode.removeChild(el);
|
||||
root.removeAttribute(attr);
|
||||
};
|
||||
root.setAttribute(attr, '1');
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
function inject_inline(fn) {
|
||||
const el = document.createElement('script');
|
||||
el.type = 'text/javascript';
|
||||
@@ -161,5 +184,8 @@
|
||||
F.__RequestWatcher = true;
|
||||
}
|
||||
|
||||
// 页面上下文通用方法:window.__mv2_simple_injected
|
||||
inject_page_file_once('injected/injected.js', 'core_utils');
|
||||
|
||||
inject_inline(request_watcher);
|
||||
})();
|
||||
|
||||
154
mv2_simple_crx/src/injected/injected.js
Normal file
154
mv2_simple_crx/src/injected/injected.js
Normal file
@@ -0,0 +1,154 @@
|
||||
(function () {
|
||||
if (window.__mv2_simple_injected) return;
|
||||
|
||||
function norm_space(s) {
|
||||
return (s || '').toString().replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function busy_wait_ms(ms) {
|
||||
var t = Number(ms);
|
||||
var dur = Number.isFinite(t) ? Math.max(0, t) : 0;
|
||||
var t0 = performance.now();
|
||||
while (performance.now() - t0 < dur) { }
|
||||
}
|
||||
|
||||
function is_visible(el) {
|
||||
if (!el) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
if (!(r.width > 0 && r.height > 0)) return false;
|
||||
// 尽量避免点击到不可见层;display/visibility 由浏览器计算
|
||||
var cs = window.getComputedStyle(el);
|
||||
if (!cs) return true;
|
||||
if (cs.display === 'none') return false;
|
||||
if (cs.visibility === 'hidden') return false;
|
||||
if (cs.opacity === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function wait_query(selectors, timeout_ms) {
|
||||
var list = Array.isArray(selectors) ? selectors : [];
|
||||
var deadline = Date.now() + (Number.isFinite(timeout_ms) ? timeout_ms : 5000);
|
||||
while (Date.now() < deadline) {
|
||||
for (var i = 0; i < list.length; i += 1) {
|
||||
var sel = list[i];
|
||||
var el = document.querySelector(sel);
|
||||
if (is_visible(el)) return el;
|
||||
}
|
||||
busy_wait_ms(40);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dispatch_human_click(target_el, options) {
|
||||
var el = target_el;
|
||||
if (!el) return false;
|
||||
var opt = options && typeof options === 'object' ? options : {};
|
||||
var pointer_id = Number.isFinite(opt.pointer_id) ? opt.pointer_id : 1;
|
||||
var pointer_type = opt.pointer_type ? String(opt.pointer_type) : 'mouse';
|
||||
|
||||
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { }
|
||||
try { el.focus && el.focus(); } catch (_) { }
|
||||
|
||||
var rect = el.getBoundingClientRect();
|
||||
var ox = Number.isFinite(opt.offset_x) ? opt.offset_x : 0;
|
||||
var oy = Number.isFinite(opt.offset_y) ? opt.offset_y : 0;
|
||||
var x = Math.max(1, Math.floor(rect.left + rect.width / 2 + ox));
|
||||
var y = Math.max(1, Math.floor(rect.top + rect.height / 2 + oy));
|
||||
var base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
|
||||
|
||||
try {
|
||||
if (typeof PointerEvent === 'function') {
|
||||
el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true }));
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 1 }));
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 0 }));
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
el.dispatchEvent(new MouseEvent('mousemove', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseover', base));
|
||||
el.dispatchEvent(new MouseEvent('mousedown', base));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', base));
|
||||
el.dispatchEvent(new MouseEvent('click', base));
|
||||
return true;
|
||||
}
|
||||
|
||||
function text(el) {
|
||||
return el && el.textContent != null ? norm_space(el.textContent) : null;
|
||||
}
|
||||
|
||||
function inner_text(el) {
|
||||
return el && el.innerText != null ? norm_space(el.innerText) : null;
|
||||
}
|
||||
|
||||
function attr(el, name) {
|
||||
if (!el || !name) return null;
|
||||
var v = el.getAttribute ? el.getAttribute(name) : null;
|
||||
return v != null ? norm_space(v) : null;
|
||||
}
|
||||
|
||||
function abs_url(href, base) {
|
||||
try {
|
||||
return new URL(href, base || location.origin).toString();
|
||||
} catch (_) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function parse_asin_from_url(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
var m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function pick_number(text0) {
|
||||
if (!text0) return null;
|
||||
var s = String(text0).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
var m = s.match(/(\d+(?:\.\d+)?)/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
function pick_int(text0) {
|
||||
if (!text0) return null;
|
||||
var raw = String(text0).replace(/\s+/g, ' ').trim();
|
||||
var up = raw.toUpperCase().replace(/,/g, '');
|
||||
var km = up.match(/([\d.]+)\s*K\b/);
|
||||
if (km) return Math.round(parseFloat(km[1]) * 1000);
|
||||
var mm = up.match(/([\d.]+)\s*M\b/);
|
||||
if (mm) return Math.round(parseFloat(mm[1]) * 1000000);
|
||||
var digits = raw.replace(/[^\d]/g, '');
|
||||
return digits ? Number(digits) : null;
|
||||
}
|
||||
|
||||
function set_input_value(input, value, options) {
|
||||
if (!input) return false;
|
||||
var opt = options && typeof options === 'object' ? options : {};
|
||||
try { input.focus && input.focus(); } catch (_) { }
|
||||
try { input.value = value == null ? '' : String(value); } catch (_) { return false; }
|
||||
if (opt.dispatch_input !== false) {
|
||||
try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) { }
|
||||
}
|
||||
if (opt.dispatch_change !== false) {
|
||||
try { input.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) { }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
window.__mv2_simple_injected = {
|
||||
norm_space: norm_space,
|
||||
busy_wait_ms: busy_wait_ms,
|
||||
wait_query: wait_query,
|
||||
is_visible: is_visible,
|
||||
dispatch_human_click: dispatch_human_click,
|
||||
text: text,
|
||||
inner_text: inner_text,
|
||||
attr: attr,
|
||||
abs_url: abs_url,
|
||||
parse_asin_from_url: parse_asin_from_url,
|
||||
pick_number: pick_number,
|
||||
pick_int: pick_int,
|
||||
set_input_value: set_input_value,
|
||||
};
|
||||
})();
|
||||
|
||||
13
mv2_simple_crx/src/libs/action_meta.js
Normal file
13
mv2_simple_crx/src/libs/action_meta.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// 统一绑定 action 元数据:集中配置,同时兼容历史 fn.desc/fn.params 读取方式。
|
||||
export function bind_action_meta(action_map, meta_map) {
|
||||
const actions = action_map && typeof action_map === 'object' ? action_map : {};
|
||||
const metas = meta_map && typeof meta_map === 'object' ? meta_map : {};
|
||||
Object.keys(metas).forEach((action_name) => {
|
||||
const action_fn = actions[action_name];
|
||||
const meta = metas[action_name] || {};
|
||||
if (typeof action_fn !== 'function') return;
|
||||
action_fn.desc = meta.desc || '';
|
||||
action_fn.params = meta.params || {};
|
||||
});
|
||||
return metas;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ const RESPONSE_CODE_OK = 0;
|
||||
const RESPONSE_CODE_BAD_REQUEST = 10;
|
||||
const RESPONSE_CODE_RUNTIME_ERROR = 30;
|
||||
|
||||
// 成功响应工厂:统一返回结构与成功码。
|
||||
export function ok_response(data) {
|
||||
return {
|
||||
code: RESPONSE_CODE_OK,
|
||||
@@ -11,6 +12,7 @@ export function ok_response(data) {
|
||||
};
|
||||
}
|
||||
|
||||
// 失败响应工厂:统一错误码、错误消息和可选上下文。
|
||||
export function fail_response(message, options) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const code = Number.isFinite(opts.code) ? Number(opts.code) : RESPONSE_CODE_RUNTIME_ERROR;
|
||||
@@ -25,8 +27,18 @@ export function fail_response(message, options) {
|
||||
};
|
||||
}
|
||||
|
||||
// 响应码常量:供业务层统一引用,避免魔法数字。
|
||||
export const response_code = {
|
||||
ok: RESPONSE_CODE_OK,
|
||||
bad_request: RESPONSE_CODE_BAD_REQUEST,
|
||||
runtime_error: RESPONSE_CODE_RUNTIME_ERROR,
|
||||
};
|
||||
|
||||
// 同步执行保护:把同步异常转为统一结果对象,避免业务层到处写 try/catch。
|
||||
export function guard_sync(task) {
|
||||
try {
|
||||
return { ok: true, data: task() };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// executeScript:MV2 使用 chrome.tabs.executeScript
|
||||
|
||||
function build_code(fn, args) {
|
||||
if (typeof fn === 'function') {
|
||||
if (Array.isArray(args) && args.length) {
|
||||
return `(${fn.toString()}).apply(null, ${JSON.stringify(args)});`;
|
||||
}
|
||||
return `(${fn.toString()})();`;
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// execute_script(tabId, fn, args?, runAt?)
|
||||
export function execute_script(tab_id, fn, args, run_at) {
|
||||
run_at = run_at || 'document_idle';
|
||||
const code = build_code(fn, args);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.executeScript(
|
||||
tab_id,
|
||||
{
|
||||
code,
|
||||
runAt: run_at,
|
||||
},
|
||||
(result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function inject_file(tab_id, file, run_at) {
|
||||
run_at = run_at || 'document_idle';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.executeScript(
|
||||
tab_id,
|
||||
{
|
||||
file,
|
||||
runAt: run_at,
|
||||
},
|
||||
() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
resolve(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,83 @@
|
||||
// openTab:MV2 版本(极简 + 回调风格)
|
||||
|
||||
import { execute_script } from './inject.js';
|
||||
function build_code(fn, args) {
|
||||
if (typeof fn === 'function') {
|
||||
if (Array.isArray(args) && args.length) {
|
||||
return `(${fn.toString()}).apply(null, ${JSON.stringify(args)});`;
|
||||
}
|
||||
return `(${fn.toString()})();`;
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// 低阶:只负责执行,不做任何前置注入。
|
||||
export function raw_execute_script(tab_id, fn, args, run_at) {
|
||||
const real_run_at = run_at || 'document_idle';
|
||||
const code = build_code(fn, args);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.executeScript(
|
||||
tab_id,
|
||||
{
|
||||
code,
|
||||
runAt: real_run_at,
|
||||
},
|
||||
(result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function inject_file(tab_id, file, run_at) {
|
||||
const real_run_at = run_at || 'document_idle';
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.executeScript(
|
||||
tab_id,
|
||||
{
|
||||
file,
|
||||
runAt: real_run_at,
|
||||
},
|
||||
() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(new Error(chrome.runtime.lastError.message));
|
||||
}
|
||||
resolve(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pick_first_frame_value(raw_list) {
|
||||
if (!Array.isArray(raw_list) || raw_list.length === 0) return null;
|
||||
return raw_list[0];
|
||||
}
|
||||
|
||||
export async function ensure_injected(tab_id) {
|
||||
const injected = pick_first_frame_value(
|
||||
await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'),
|
||||
);
|
||||
if (injected === true) return true;
|
||||
|
||||
// 约定:扩展根目录=src,因此 file 使用 src 内相对路径
|
||||
await inject_file(tab_id, 'injected/injected.js', 'document_idle');
|
||||
const injected2 = pick_first_frame_value(
|
||||
await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'),
|
||||
);
|
||||
return injected2 === true;
|
||||
}
|
||||
|
||||
// 高阶:默认确保 injected 通用方法已加载。
|
||||
export async function execute_script(tab_id, fn, args, run_at, options) {
|
||||
const ensure = !(options && options.ensure_injected === false);
|
||||
if (ensure) {
|
||||
await ensure_injected(tab_id);
|
||||
}
|
||||
return await raw_execute_script(tab_id, fn, args, run_at);
|
||||
}
|
||||
|
||||
function update_tab(tab_id, update_props) {
|
||||
return new Promise((resolve_update, reject_update) => {
|
||||
@@ -28,6 +105,14 @@ function attach_tab_helpers(tab) {
|
||||
return await execute_script(tab.id, fn, args, run_at);
|
||||
};
|
||||
|
||||
tab.inject_file = async function inject_file_on_tab(file, run_at) {
|
||||
return await inject_file(tab.id, file, run_at);
|
||||
};
|
||||
|
||||
tab.ensure_injected = async function ensure_injected_on_tab() {
|
||||
return await ensure_injected(tab.id);
|
||||
};
|
||||
|
||||
tab.navigate = async function navigate(url, options) {
|
||||
const nav_options = options && typeof options === 'object' ? options : {};
|
||||
const active = Object.prototype.hasOwnProperty.call(nav_options, 'active') ? nav_options.active === true : true;
|
||||
@@ -111,6 +196,16 @@ function attach_tab_helpers(tab) {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 等待一次 on_update_complete 并返回 worker 结果。
|
||||
tab.wait_update_complete_once = function wait_update_complete_once(worker) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tab.on_update_complete(async () => {
|
||||
const output = await worker(tab);
|
||||
resolve(output);
|
||||
}, { once: true, on_error: reject });
|
||||
});
|
||||
};
|
||||
|
||||
tab.off_update_complete = function off_update_complete() {
|
||||
if (!tab._on_update_complete_listener) return;
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"content/request_watcher.js"
|
||||
"content/request_watcher.js",
|
||||
"injected/injected.js"
|
||||
],
|
||||
"browser_action": {
|
||||
"default_title": "mv2_simple_crx"
|
||||
|
||||
Reference in New Issue
Block a user