diff --git a/mv2_simple_crx/src/actions/amazon.js b/mv2_simple_crx/src/actions/amazon.js index cf7b310..f36c155 100644 --- a/mv2_simple_crx/src/actions/amazon.js +++ b/mv2_simple_crx/src/actions/amazon.js @@ -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, + ), + }, +]; diff --git a/mv2_simple_crx/src/actions/amazon_tool.js b/mv2_simple_crx/src/actions/amazon_tool.js index 8cf71a5..745e02c 100644 --- a/mv2_simple_crx/src/actions/amazon_tool.js +++ b/mv2_simple_crx/src/actions/amazon_tool.js @@ -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; +} diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js index 8b74119..968cb91 100644 --- a/mv2_simple_crx/src/background/index.js +++ b/mv2_simple_crx/src/background/index.js @@ -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) { diff --git a/mv2_simple_crx/src/content/content.js b/mv2_simple_crx/src/content/content.js index e32c175..482e103 100644 --- a/mv2_simple_crx/src/content/content.js +++ b/mv2_simple_crx/src/content/content.js @@ -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); })(); diff --git a/mv2_simple_crx/src/injected/injected.js b/mv2_simple_crx/src/injected/injected.js new file mode 100644 index 0000000..8a7684a --- /dev/null +++ b/mv2_simple_crx/src/injected/injected.js @@ -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, + }; +})(); + diff --git a/mv2_simple_crx/src/libs/action_meta.js b/mv2_simple_crx/src/libs/action_meta.js new file mode 100644 index 0000000..80ab59a --- /dev/null +++ b/mv2_simple_crx/src/libs/action_meta.js @@ -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; +} diff --git a/mv2_simple_crx/src/libs/action_response.js b/mv2_simple_crx/src/libs/action_response.js index f3810ca..de5204e 100644 --- a/mv2_simple_crx/src/libs/action_response.js +++ b/mv2_simple_crx/src/libs/action_response.js @@ -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 }; + } +} diff --git a/mv2_simple_crx/src/libs/inject.js b/mv2_simple_crx/src/libs/inject.js deleted file mode 100644 index f9ef9e0..0000000 --- a/mv2_simple_crx/src/libs/inject.js +++ /dev/null @@ -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); - }, - ); - }); -} diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js index ac8ffe8..a425038 100644 --- a/mv2_simple_crx/src/libs/tabs.js +++ b/mv2_simple_crx/src/libs/tabs.js @@ -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 { diff --git a/mv2_simple_crx/src/manifest.json b/mv2_simple_crx/src/manifest.json index a198f2e..ff9e40d 100644 --- a/mv2_simple_crx/src/manifest.json +++ b/mv2_simple_crx/src/manifest.json @@ -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"