diff --git a/1.md b/README.md similarity index 100% rename from 1.md rename to README.md diff --git a/mv2_simple_crx/src/actions/amazon.js b/mv2_simple_crx/src/actions/amazon.js index 55630f9..cf7b310 100644 --- a/mv2_simple_crx/src/actions/amazon.js +++ b/mv2_simple_crx/src/actions/amazon.js @@ -1,6 +1,7 @@ // 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, @@ -10,7 +11,6 @@ import { injected_amazon_validate_captcha_continue, normalize_product_url, try_solve_amazon_validate_captcha, - wait_tab_complete, wait_until_search_list_url, } from './amazon_tool.js'; @@ -19,7 +19,7 @@ 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(async (resolve, reject) => { + 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; @@ -53,114 +53,82 @@ export function amazon_search_list(data, sendResponse) { .set_target('__amazon_search_list'); let url = AMAZON_ZH_HOME_URL; - try { - const tab = await tab_task.open_async(); - let running = false; - let resolved = false; + 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); - tab.on_update_complete(async (t) => { - // 刷新/导航完成后也能看到注入轨迹(不重复跑主流程) - await t.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle'); - - if (running) return; - running = true; - try { - await try_solve_amazon_validate_captcha(t, 3); - - const home_ret = await t.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(t.id, 45000); - await t.wait_complete(); - await try_solve_amazon_validate_captcha(t, 3); - - if (sort_s) { - const u = new URL(url); - u.searchParams.set('s', sort_s); - url = u.toString(); - await new Promise((resolve_nav, reject_nav) => { - chrome.tabs.update(t.id, { url, active: true }, () => { - if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message)); - resolve_nav(true); - }); - }); - await t.wait_complete(); - await try_solve_amazon_validate_captcha(t, 3); - } - - 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 new Promise((resolve_nav, reject_nav) => { - chrome.tabs.update(t.id, { url: next_url, active: true }, () => { - if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message)); - resolve_nav(true); - }); - }); - await t.wait_complete(); - await try_solve_amazon_validate_captcha(t, 3); - } - const injected_result_list = await t.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 = { code: 0, status: true, message: 'ok', data: { tab_id: t.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result } }; - - send_action('amazon_search_list', result); - if (!resolved) { - resolved = true; - resolve({ tab_id: t.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result }); - } - if (!keep_tab_open) { - t.remove(0); - } - } catch (err) { - send_action('amazon_search_list', { - code: 30, - status: false, - message: (err && err.message) || String(err), - data: null, - documentURI: url || AMAZON_ZH_HOME_URL, - }); - if (!resolved) { - resolved = true; - reject(err); - } - if (!keep_tab_open) { - t.remove(0); - } - } finally { - running = false; + 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', { - code: 30, - status: false, - message: (err && err.message) || String(err), - data: null, + }) + .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); - } + }); }); } @@ -173,7 +141,7 @@ amazon_search_list.params = { }; export function amazon_set_language(data, sendResponse) { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { const mapping = { EN: 'en_US', ES: 'es_US', @@ -198,31 +166,44 @@ export function amazon_set_language(data, sendResponse) { const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG) .set_latest(false) .set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - try { - const tab = await tab_task.open_async(); - tab.on_update_complete(async (t) => { + tab_task.open_async() + .then((tab) => { + tab.on_update_complete(async () => { // 首次 complete 也会触发:在回调里完成注入与结果采集 - await t.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - const raw = await t.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle'); + 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(t.id, (tt) => { + 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 = { code: 0, status: true, message: 'ok', data: { tab_id: t.id, lang: inj.lang, url: final_url } }; + const result = ok_response({ tab_id: tab.id, lang: inj.lang, url: final_url }); send_action('amazon_set_language', result); - resolve({ tab_id: t.id, lang: inj.lang, url: final_url }); - t.remove(0); - }, { once: true }); - } catch (err) { - send_action('amazon_set_language', { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: AMAZON_HOME_FOR_LANG }); + 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); - } + }); }); } @@ -271,30 +252,45 @@ function run_pdp_action(product_url, injected_fn, inject_args, action_name, send sendResponse.log && sendResponse.log(payload); } }; - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { let url = product_url; - try { - url = normalize_product_url(product_url); - } catch (e) { - send_action(action_name, { code: 10, status: false, message: e.message, data: null }); - return reject(e); - } - const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - try { - const tab = await tab_task.open_async(); - tab.on_update_complete(async (t) => { - await t.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - await try_solve_amazon_validate_captcha(t, 3); - const raw_list = await t.execute_script(injected_fn, inject_args || [], 'document_idle'); + 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, { code: 0, status: true, message: 'ok', data: { tab_id: t.id, product_url: url, result } }); - resolve({ tab_id: t.id, product_url: url, result }); - t.remove(0); - }, { once: true }); - } catch (err) { - send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url }); - reject(err); - } + 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); + }); }); } @@ -305,38 +301,52 @@ function run_pdp_action_multi(product_url, steps, action_name, sendResponse) { sendResponse.log && sendResponse.log(payload); } }; - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { let url = product_url; - try { - url = normalize_product_url(product_url); - } catch (e) { - send_action(action_name, { code: 10, status: false, message: e.message, data: null }); - return reject(e); - } - - const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - try { - const tab = await tab_task.open_async(); - tab.on_update_complete(async (t) => { - await t.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - await try_solve_amazon_validate_captcha(t, 3); + 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 t.execute_script(step.injected_fn, step.inject_args || [], 'document_idle'); + 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, { code: 0, status: true, message: 'ok', data: { tab_id: t.id, product_url: url, result: results } }); - resolve({ tab_id: t.id, product_url: url, result: results }); - t.remove(0); - }, { once: true }); - } catch (err) { - send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url }); - reject(err); - } + 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); + }); }); } diff --git a/mv2_simple_crx/src/actions/amazon_tool.js b/mv2_simple_crx/src/actions/amazon_tool.js index 107f878..8cf71a5 100644 --- a/mv2_simple_crx/src/actions/amazon_tool.js +++ b/mv2_simple_crx/src/actions/amazon_tool.js @@ -96,28 +96,63 @@ export async function try_solve_amazon_validate_captcha(tab, max_round) { export function injected_amazon_homepage_search(params) { const keyword = params && params.keyword ? String(params.keyword).trim() : ''; if (!keyword) return { ok: false, error: 'empty_keyword' }; - const input = - document.querySelector('#twotabsearchtextbox') || - document.querySelector('input#nav-search-keywords') || - document.querySelector('input[name="field-keywords"]'); + + 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 input = wait_query([ + '#twotabsearchtextbox', + 'input#nav-search-keywords', + 'input[name="field-keywords"]', + '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 })); - const btn = - document.querySelector('#nav-search-submit-button') || - document.querySelector('#nav-search-bar-form input[type="submit"]') || - document.querySelector('form[role="search"] input[type="submit"]'); + const btn = wait_query([ + '#nav-search-submit-button', + '#nav-search-bar-form input[type="submit"]', + '#nav-search-bar-form button[type="submit"]', + 'form[role="search"] input[type="submit"]', + 'form[role="search"] button[type="submit"]', + 'input.nav-input[type="submit"]', + ], 2000); if (btn) { return { ok: dispatch_human_click(btn) }; } - const form = input.closest('form'); - if (form) { - form.submit(); - return { ok: true }; + const form = input.form || input.closest('form'); + if (form && typeof form.requestSubmit === 'function') { + form.requestSubmit(); + return { ok: true, method: 'request_submit' }; } - return { ok: false, error: 'no_submit' }; + if (form && typeof form.submit === 'function') { + form.submit(); + return { ok: true, method: 'form_submit' }; + } + try { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); + return { ok: true, method: 'keyboard_enter' }; + } catch (_) { + // ignore and return explicit error + } + return { ok: false, error: 'no_submit', keyword }; } export function injected_amazon_switch_language(params) { diff --git a/mv2_simple_crx/src/libs/action_response.js b/mv2_simple_crx/src/libs/action_response.js new file mode 100644 index 0000000..f3810ca --- /dev/null +++ b/mv2_simple_crx/src/libs/action_response.js @@ -0,0 +1,32 @@ +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, + status: true, + message: 'ok', + data: data == null ? null : 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; + const data = Object.prototype.hasOwnProperty.call(opts, 'data') ? opts.data : null; + const documentURI = Object.prototype.hasOwnProperty.call(opts, 'documentURI') ? opts.documentURI : undefined; + return { + code, + status: false, + message: message ? String(message) : 'error', + data, + ...(documentURI ? { documentURI } : {}), + }; +} + +export const response_code = { + ok: RESPONSE_CODE_OK, + bad_request: RESPONSE_CODE_BAD_REQUEST, + runtime_error: RESPONSE_CODE_RUNTIME_ERROR, +}; diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js index c8ba229..ac8ffe8 100644 --- a/mv2_simple_crx/src/libs/tabs.js +++ b/mv2_simple_crx/src/libs/tabs.js @@ -2,6 +2,17 @@ import { execute_script } from './inject.js'; +function update_tab(tab_id, update_props) { + return new Promise((resolve_update, reject_update) => { + chrome.tabs.update(tab_id, update_props, (updated_tab) => { + if (chrome.runtime.lastError) { + return reject_update(new Error(chrome.runtime.lastError.message)); + } + resolve_update(updated_tab || true); + }); + }); +} + function attach_tab_helpers(tab) { if (!tab) return tab; @@ -17,6 +28,13 @@ function attach_tab_helpers(tab) { return await execute_script(tab.id, fn, args, run_at); }; + tab.navigate = async function navigate(url, options) { + const nav_options = options && typeof options === 'object' ? options : {}; + const active = Object.prototype.hasOwnProperty.call(nav_options, 'active') ? nav_options.active === true : true; + const update_props = { url: String(url), active }; + return await update_tab(tab.id, update_props); + }; + /** * 等待 tab 页面加载完成(status=complete) * - 作为 tab 方法,避免业务层到处传 tab_id @@ -56,20 +74,25 @@ function attach_tab_helpers(tab) { let running = false; const once = !!(options && options.once === true); + const on_error = options && typeof options.on_error === 'function' ? options.on_error : null; const listener = async (updated_tab_id, change_info, updated_tab) => { if (updated_tab_id !== tab.id) return; if (!change_info || change_info.status !== 'complete') return; if (running) return; running = true; + const tab_obj = attach_tab_helpers(updated_tab || tab); try { - const tab_obj = attach_tab_helpers(updated_tab || tab); await fn(tab_obj, change_info); if (once) { tab.off_update_complete && tab.off_update_complete(); } } catch (err) { - // eslint-disable-next-line no-console - console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) }); + if (on_error) { + on_error(err, tab_obj, change_info); + } else { + // eslint-disable-next-line no-console + console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) }); + } } finally { running = false; } @@ -138,7 +161,11 @@ export function open_tab(url, options) { }; chrome.tabs.onUpdated.addListener(on_updated); - chrome.tabs.update(tab_id, { url }); + update_tab(tab_id, { url }) + .catch((err) => { + chrome.tabs.onUpdated.removeListener(on_updated); + reject(err); + }); }, ); }); @@ -210,12 +237,7 @@ export function create_tab_task(url) { throw new Error('popup window 创建失败'); } - await new Promise((resolve, reject) => { - chrome.tabs.update(tab0.id, { url: this.url, active: this.active !== false }, () => { - if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); - resolve(true); - }); - }); + await update_tab(tab0.id, { url: this.url, active: this.active !== false }); const tab_done = await new Promise((resolve) => { const on_updated = (tab_id, change_info, tab) => {