From 7164dc3ed56823205d183eec615001dfeea2f5ff Mon Sep 17 00:00:00 2001 From: light <978854603@qq.com> Date: Thu, 26 Mar 2026 20:56:06 +0800 Subject: [PATCH] 1 --- mv2_simple_crx/src/actions/amazon.js | 331 ++++--- mv2_simple_crx/src/actions/amazon_tool.js | 485 +++------- mv2_simple_crx/src/background/index.js | 206 ++-- mv2_simple_crx/src/libs/tabs.js | 891 ++++++------------ server/app.js | 2 +- server/config/database.js | 4 +- server/scripts/db_sync.js | 2 +- .../amazon_search_detail_reviews_flow.js | 2 + server/services/puppeteer/puppeteer_runner.js | 9 +- server/services/schedule_loader.js | 10 +- 10 files changed, 667 insertions(+), 1275 deletions(-) diff --git a/mv2_simple_crx/src/actions/amazon.js b/mv2_simple_crx/src/actions/amazon.js index d6f0ce5..604740d 100644 --- a/mv2_simple_crx/src/actions/amazon.js +++ b/mv2_simple_crx/src/actions/amazon.js @@ -1,142 +1,184 @@ -// Amazon:直接在 handler 中编写逻辑,简化封装层级 - - import { create_tab_task, ok_response, fail_response, guard_sync, response_code, sleep_ms, get_tab_url } from '../libs/index.js'; -import { injected_amazon_validate_captcha_continue, injected_amazon_product_detail, injected_amazon_product_reviews, normalize_product_url, pick_first_script_result, run_amazon_pdp_action_multi, injected_amazon_switch_language, injected_amazon_search_list, injected_amazon_homepage_search, try_solve_amazon_validate_captcha, wait_until_search_list_url } from './amazon_tool.js'; +import { + injected_amazon_validate_captcha_continue, + injected_amazon_product_detail, + injected_amazon_product_reviews, + injected_amazon_switch_language, + injected_amazon_search_list, + injected_amazon_homepage_search, + injected_detect_captcha_page, + normalize_product_url, + pick_first_script_result, + try_solve_amazon_validate_captcha, + wait_until_search_list_url, +} from './amazon_tool.js'; + const AMAZON_HOME_FOR_LANG = 'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN¤cy=HKD'; const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo'; -// 公共的 send_action 函数 -const create_send_action = (sendResponse) => (actionName, response) => { - sendResponse({ action: actionName, ...response }); +// ──────────── 公共工具 ──────────── + +const create_send_action = (sendResponse) => (action_name, response) => { + sendResponse({ action: action_name, ...response }); }; +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', +}; + +function parse_limit(data, default_val, max_val) { + const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : default_val; + if (!Number.isFinite(n)) return default_val; + return Math.max(1, Math.min(max_val, Math.floor(n))); +} + +// ──────────── 核心业务函数(从 handler 回调中提取) ──────────── + +/** + * 搜索列表:验证码检测 -> 首页搜索 -> 排序 -> 分页抓取 + */ +async function do_search_list(tab, { keyword, sort_s, category_keyword, sort_by, limit }) { + // DOM 检测验证码页 + const captcha_ret = await tab.execute_script(injected_detect_captcha_page, [], 'document_idle'); + if (pick_first_script_result(captcha_ret) === true) { + await try_solve_amazon_validate_captcha(tab, 3); + } + + // 首页搜索 + const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle'); + const home_ok = pick_first_script_result(home_ret); + if (!home_ok || !home_ok.ok) { + const current_url = await get_tab_url(tab.id).catch(() => ''); + const detail = home_ok && typeof home_ok === 'object' ? JSON.stringify(home_ok) : String(home_ok); + throw new Error(`首页搜索提交失败: ${detail}; url=${current_url || 'unknown'}`); + } + + // 等待跳转到列表页 + let url = await wait_until_search_list_url(tab.id, 45000); + await tab.wait_complete(); + + // 排序 + if (sort_s) { + const u = new URL(url); + u.searchParams.set('s', sort_s); + 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 raw = await tab.execute_script(injected_amazon_search_list, [{ url: next_url, category_keyword, sort_by }], 'document_idle'); + const result = pick_first_script_result(raw); + const items = result && Array.isArray(result.items) ? result.items : []; + items.forEach((it) => { + const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null; + if (k && !unique_map.has(k)) unique_map.set(k, it); + }); + if (unique_map.size >= limit) break; + next_url = result && result.next_url ? String(result.next_url) : null; + if (!next_url) break; + } + + const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) }; + return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result }; +} + +/** + * 切换语言 + */ +async function do_set_language(tab, code) { + await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); + const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle'); + const inj = pick_first_script_result(raw); + if (!inj || !inj.ok) { + throw new Error((inj && inj.error) || 'switch_language_failed'); + } + const final_url = await get_tab_url(tab.id); + return { tab_id: tab.id, lang: inj.lang, url: final_url }; +} + +/** + * PDP 多步骤注入(detail + reviews 等) + */ +async function do_pdp_steps(tab, url, steps) { + await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); + await try_solve_amazon_validate_captcha(tab, 3); + const results = {}; + for (const step of steps) { + if (!step || !step.name || typeof step.injected_fn !== 'function') continue; + const raw = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle'); + results[step.name] = pick_first_script_result(raw); + } + return { tab_id: tab.id, product_url: url, result: results }; +} + +// ──────────── Action 定义 ──────────── + export const amazon_actions = [ { name: 'amazon_search_list', desc: 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取', params: { - category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' }, + category_keyword: { type: 'string', desc: '类目关键词', default: '野餐包' }, sort_by: { type: 'string', desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' }, limit: { type: 'number', desc: '抓取数量上限(默认 100,最大 200)', default: 100 }, - keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: true }, + keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口', default: true }, }, handler: async (data, sendResponse) => { const send_action = create_send_action(sendResponse); - const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : ''; const sort_by = data && data.sort_by ? String(data.sort_by).trim() : ''; const keep_tab_open = data && data.keep_tab_open === true; - const limit = (() => { - const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100; - if (!Number.isFinite(n)) return 100; - return Math.max(1, Math.min(200, Math.floor(n))); - })(); + const limit = parse_limit(data, 100, 200); 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 sort_s = SORT_MAP[sort_by] || ''; const tab_task = create_tab_task(AMAZON_ZH_HOME_URL) .set_latest(false) .set_bounds({ top: 20, left: 20, width: 1440, height: 900 }) .set_target('__amazon_search_list'); - let url = AMAZON_ZH_HOME_URL; + let tab = null; try { - const tab = await tab_task.open_async(); - const payload = await tab.wait_update_complete_once(async () => { - const href = await get_tab_url(tab.id).catch(() => ''); - const is_captcha = String(href).includes('/errors/validateCaptcha'); - if(is_captcha) { - await try_solve_amazon_validate_captcha(tab, 3); - } - - // 如果在首页搜索页面,则执行首页搜索 - - const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle'); - let home_ok = pick_first_script_result(home_ret); - if (!home_ok || !home_ok.ok) { - const current_url = await get_tab_url(tab.id).catch(() => ''); - const detail = home_ok && typeof home_ok === 'object' - ? JSON.stringify(home_ok) - : String(home_ok); - throw new Error(`首页搜索提交失败: ${detail}; url=${current_url || 'unknown'}`); - } - url = await wait_until_search_list_url(tab.id, 45000); - await tab.wait_complete(); - if (sort_s) { - const u = new URL(url); - u.searchParams.set('s', sort_s); - url = u.toString(); - await tab.navigate(url); - await tab.wait_complete(); - } - const unique_map = new Map(); - let next_url = url; - for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) { - if (page > 1) { - await tab.navigate(next_url); - await tab.wait_complete(); - } - const injected_result_list = await tab.execute_script( - injected_amazon_search_list, - [{ url: next_url, category_keyword, sort_by }], - 'document_idle', - ); - const injected_result = pick_first_script_result(injected_result_list); - const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : []; - items.forEach((it) => { - const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null; - if (!k) return; - if (!unique_map.has(k)) unique_map.set(k, it); - }); - if (unique_map.size >= limit) break; - next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null; - if (!next_url) break; - } - const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) }; - return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result }; - }); + tab = await tab_task.open_async(); + const payload = await tab.wait_update_complete_once(() => + do_search_list(tab, { keyword, sort_s, category_keyword, sort_by, limit }) + ); send_action('amazon_search_list', ok_response(payload)); if (!keep_tab_open) tab.remove(0); return payload; } catch (err) { send_action('amazon_search_list', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, - documentURI: url || AMAZON_ZH_HOME_URL, + documentURI: AMAZON_ZH_HOME_URL, })); - if (!keep_tab_open) tab.remove(0); + if (tab && !keep_tab_open) tab.remove(0); throw err; } }, }, + { name: 'amazon_set_language', - desc: 'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言', + desc: 'Amazon 顶栏语言切换', params: { lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' }, }, handler: async (data, sendResponse) => { const send_action = create_send_action(sendResponse); - - const mapping = { - EN: 'en_US', - ES: 'es_US', - AR: 'ar_AE', - DE: 'de_DE', - HE: 'he_IL', - KO: 'ko_KR', - PT: 'pt_BR', - ZH_CN: 'zh_CN', - ZH_TW: 'zh_TW', - }; + const mapping = { EN: 'en_US', ES: 'es_US', AR: 'ar_AE', DE: 'de_DE', HE: 'he_IL', KO: 'ko_KR', PT: 'pt_BR', ZH_CN: 'zh_CN', ZH_TW: 'zh_TW' }; const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN'; const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN'; @@ -146,22 +188,7 @@ export const amazon_actions = [ try { const tab = await tab_task.open_async(); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle'); - const inj = pick_first_script_result(raw); - if (!inj || !inj.ok) { - throw new Error((inj && inj.error) || 'switch_language_failed'); - } - const final_url = await new Promise((resolve_url, reject_url) => { - chrome.tabs.get(tab.id, (tab_info) => { - if (chrome.runtime.lastError) return reject_url(new Error(chrome.runtime.lastError.message)); - resolve_url(tab_info && tab_info.url ? tab_info.url : ''); - }); - }); - return { tab_id: tab.id, lang: inj.lang, url: final_url }; - }); - + const payload = await tab.wait_update_complete_once(() => do_set_language(tab, code)); send_action('amazon_set_language', ok_response(payload)); tab.remove(0); return payload; @@ -174,90 +201,71 @@ export const amazon_actions = [ } }, }, + { name: 'amazon_product_detail', - desc: 'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)', + desc: 'Amazon 商品详情(标题、价格、品牌、要点、配送摘要等)', params: { product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' }, }, handler: async (data, sendResponse) => { const send_action = create_send_action(sendResponse); - const normalized = guard_sync(() => normalize_product_url(data && data.product_url)); if (!normalized.ok) { - send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { - code: response_code.bad_request, - })); + send_action('amazon_product_detail', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request })); return; } - const url = normalized.data; const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); try { const tab = await tab_task.open_async(); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - const raw_list = await tab.execute_script(injected_amazon_product_detail, [], 'document_idle'); - const result = pick_first_script_result(raw_list); - return { tab_id: tab.id, product_url: url, result }; - }); - + const payload = await tab.wait_update_complete_once(() => + do_pdp_steps(tab, url, [{ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }]) + ); send_action('amazon_product_detail', ok_response(payload)); tab.remove(0); return payload; } catch (err) { - send_action('amazon_product_detail', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); + send_action('amazon_product_detail', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url })); throw err; } }, }, + { name: 'amazon_product_reviews', - desc: 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)', + desc: 'Amazon 商品页买家评论', params: { product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' }, limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 }, }, handler: async (data, sendResponse) => { const send_action = create_send_action(sendResponse); - const normalized = guard_sync(() => normalize_product_url(data && data.product_url)); if (!normalized.ok) { - send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { - code: response_code.bad_request, - })); + send_action('amazon_product_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request })); return; } - const url = normalized.data; + const limit = parse_limit(data, 50, 100); const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); try { const tab = await tab_task.open_async(); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - const limit = data && data.limit != null ? Number(data.limit) : 50; - const raw_list = await tab.execute_script(injected_amazon_product_reviews, [{ limit }], 'document_idle'); - const result = pick_first_script_result(raw_list); - return { tab_id: tab.id, product_url: url, result }; - }); - + const payload = await tab.wait_update_complete_once(() => + do_pdp_steps(tab, url, [{ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] }]) + ); send_action('amazon_product_reviews', ok_response(payload)); tab.remove(0); return payload; } catch (err) { - send_action('amazon_product_reviews', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); + send_action('amazon_product_reviews', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url })); throw err; } }, }, + { name: 'amazon_product_detail_reviews', desc: 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)', @@ -266,14 +274,31 @@ export const amazon_actions = [ limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 }, skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false }, }, - handler: (data, sendResponse) => run_amazon_pdp_action_multi( - data && data.product_url, - [ + handler: async (data, sendResponse) => { + const send_action = create_send_action(sendResponse); + const normalized = guard_sync(() => normalize_product_url(data && data.product_url)); + if (!normalized.ok) { + send_action('amazon_product_detail_reviews', fail_response((normalized.error && normalized.error.message) || String(normalized.error), { code: response_code.bad_request })); + return; + } + const url = normalized.data; + const limit = parse_limit(data, 50, 100); + const steps = [ ...(data && data.skip_detail === true ? [] : [{ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }]), - { name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit: data && data.limit != null ? Number(data.limit) : 50 }] }, - ], - 'amazon_product_detail_reviews', - sendResponse, - ), + { name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] }, + ]; + const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); + + try { + const tab = await tab_task.open_async(); + const payload = await tab.wait_update_complete_once(() => do_pdp_steps(tab, url, steps)); + send_action('amazon_product_detail_reviews', ok_response(payload)); + tab.remove(0); + return payload; + } catch (err) { + send_action('amazon_product_detail_reviews', fail_response((err && err.message) || String(err), { code: response_code.runtime_error, documentURI: url })); + throw err; + } + }, }, ]; diff --git a/mv2_simple_crx/src/actions/amazon_tool.js b/mv2_simple_crx/src/actions/amazon_tool.js index e153c63..279c7f8 100644 --- a/mv2_simple_crx/src/actions/amazon_tool.js +++ b/mv2_simple_crx/src/actions/amazon_tool.js @@ -1,44 +1,43 @@ -import { create_tab_task, ok_response, fail_response, guard_sync, response_code, sleep_ms, get_tab_url } from '../libs/index.js'; +import { sleep_ms, get_tab_url } from '../libs/index.js'; -// Amazon:注入函数 + action 实现(amazon.js 仅保留 action 壳) +// Amazon:页面注入函数 + 纯工具 // // 约定: // - injected_* 在页面上下文执行,只依赖 DOM -// - 每个 action 打开 tab 后,通过 tab.set_on_complete_inject 绑定 onUpdated(status=complete) 注入钩子 +// - 闭包外变量不会进入页面,辅助函数只能写在各 injected_* 函数体内 -// ---------- 页面注入(仅依赖页面 DOM) ---------- -// 注意:injected_* 会经 tabs.executeScript 序列化执行,闭包外变量不会进入页面,辅助函数只能写在各 injected_* 函数体内。 +// ──────────── 验证码相关 ──────────── export function injected_amazon_validate_captcha_continue() { const injected_utils = () => window.__mv2_simple_injected || null; - const dispatch_human_click = (target_el, options) => { + const dispatch_human_click = (target_el) => { const u = injected_utils(); - if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options); + if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el); return false; }; - const href = location.href || ''; - const is_captcha = href.includes('/errors/validateCaptcha'); - if (!is_captcha) return { ok: true, is_captcha: false, clicked: false, href }; + + // 基于 DOM 特征判断验证码页 + const form = document.querySelector('form[action*="/errors/validateCaptcha"]'); + if (!form) return { ok: true, is_captcha: false, clicked: false, href: location.href }; const btn = - document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') || - document.querySelector('form[action*="validateCaptcha"] input[type="submit"]') || - document.querySelector('form[action*="validateCaptcha"] button[type="submit"]') || + form.querySelector('button[type="submit"].a-button-text') || + form.querySelector('input[type="submit"]') || + form.querySelector('button[type="submit"]') || document.querySelector('input[type="submit"][value*="Continue"]') || document.querySelector('button[type="submit"]'); const clicked = btn ? dispatch_human_click(btn) : false; if (!clicked) { - const form = document.querySelector('form[action*="validateCaptcha"]'); - if (form) { - try { - form.submit(); - return { ok: true, is_captcha: true, clicked: true, method: 'submit', href }; - } catch (_) { } + try { + form.submit(); + return { ok: true, is_captcha: true, clicked: true, method: 'submit', href: location.href }; + } catch (_) { + return { ok: false, is_captcha: true, clicked: false, method: 'submit', href: location.href }; } } - return { ok: true, is_captcha: true, clicked, method: clicked ? 'dispatch' : 'none', href }; + return { ok: true, is_captcha: true, clicked, method: clicked ? 'dispatch' : 'none', href: location.href }; } export function is_amazon_validate_captcha_url(tab_url) { @@ -46,6 +45,36 @@ export function is_amazon_validate_captcha_url(tab_url) { return tab_url.includes('amazon.') && tab_url.includes('/errors/validateCaptcha'); } +/** + * 基于 DOM 特征检测验证码页(注入到页面执行) + */ +export function injected_detect_captcha_page() { + const form = document.querySelector('form[action*="/errors/validateCaptcha"]'); + const btn = document.querySelector( + 'form[action*="/errors/validateCaptcha"] button[type="submit"], form[action*="/errors/validateCaptcha"] input[type="submit"]' + ); + const has_continue_h4 = Array.from(document.querySelectorAll('h4')).some((el) => { + const txt = (el.textContent || '').trim().toLowerCase(); + return txt.includes('continue shopping'); + }); + return !!(form && (btn || has_continue_h4)); +} + +export async function try_solve_amazon_validate_captcha(tab, max_round) { + const rounds = Number.isFinite(max_round) ? Math.max(1, Math.min(5, Math.floor(max_round))) : 2; + for (let i = 0; i < rounds; i += 1) { + const url = await get_tab_url(tab.id).catch(() => ''); + if (!is_amazon_validate_captcha_url(url)) return true; + await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); + await sleep_ms(800 + Math.floor(Math.random() * 600)); + await tab.wait_complete(); + await sleep_ms(300); + } + return false; +} + +// ──────────── 结果工具 ──────────── + export function pick_first_script_result(raw_list) { if (!Array.isArray(raw_list) || raw_list.length === 0) return null; const first = raw_list[0]; @@ -55,48 +84,21 @@ export function pick_first_script_result(raw_list) { return first; } -/** 由 background 传入的 sendResponse 生成统一推送(含可选 .log) */ -function create_send_action(sendResponse) { - return (action, payload) => { - if (typeof sendResponse !== 'function') return; - sendResponse({ action, data: payload }); - if (typeof sendResponse.log === 'function') sendResponse.log(payload); - }; -} - -export async function try_solve_amazon_validate_captcha(tab, max_round) { - const rounds = Number.isFinite(max_round) ? Math.max(1, Math.min(5, Math.floor(max_round))) : 2; - for (let i = 0; i < rounds; i += 1) { - const tab_state = await new Promise((resolve) => { - chrome.tabs.get(tab.id, (t) => resolve(t || null)); - }); - const url = tab_state && tab_state.url ? String(tab_state.url) : ''; - if (!is_amazon_validate_captcha_url(url)) return true; - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - await sleep_ms(800 + Math.floor(Math.random() * 600)); - await tab.wait_complete(); - await sleep_ms(300); - } - return false; -} +// ──────────── 首页搜索(注入) ──────────── export function injected_amazon_homepage_search(params) { const injected_utils = () => window.__mv2_simple_injected || null; - const dispatch_human_click = (target_el, options) => { + const dispatch_human_click = (target_el) => { const u = injected_utils(); - if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options); + if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el); return false; }; const keyword = params && params.keyword ? String(params.keyword).trim() : ''; if (!keyword) return { ok: false, error: 'empty_keyword' }; const u = injected_utils(); - const wait_query = u && typeof u.wait_query === 'function' - ? u.wait_query - : () => null; - const set_input_value = u && typeof u.set_input_value === 'function' - ? u.set_input_value - : () => false; + const wait_query = u && typeof u.wait_query === 'function' ? u.wait_query : () => null; + const set_input_value = u && typeof u.set_input_value === 'function' ? u.set_input_value : () => false; const input = wait_query([ '#twotabsearchtextbox', @@ -106,6 +108,7 @@ export function injected_amazon_homepage_search(params) { ], 7000); if (!input) return { ok: false, error: 'no_search_input' }; set_input_value(input, keyword); + const btn = wait_query([ '#nav-search-submit-button', '#nav-search-bar-form input[type="submit"]', @@ -116,66 +119,47 @@ export function injected_amazon_homepage_search(params) { ], 2000); if (btn) { const clicked = dispatch_human_click(btn); - if (clicked) { - return { ok: true, method: 'button_click' }; - } + if (clicked) return { ok: true, method: 'button_click' }; } + const form = input.form || input.closest('form'); if (form && typeof form.requestSubmit === 'function') { - try { - form.requestSubmit(); - return { ok: true, method: 'request_submit' }; - } catch (_) { - // continue fallback - } + try { form.requestSubmit(); return { ok: true, method: 'request_submit' }; } catch (_) {} } if (form && typeof form.submit === 'function') { - try { - form.submit(); - return { ok: true, method: 'form_submit' }; - } catch (_) { - // continue fallback - } + try { form.submit(); return { ok: true, method: 'form_submit' }; } catch (_) {} } + try { input.focus(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); input.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); - // 兜底:直接派发 submit 事件 - if (form) { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - } + if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); return { ok: true, method: 'keyboard_enter' }; - } catch (_) { - // ignore and return explicit error - } + } catch (_) {} return { ok: false, error: 'submit_all_fallback_failed', keyword }; } +// ──────────── 切换语言(注入) ──────────── + export function injected_amazon_switch_language(params) { const injected_utils = () => window.__mv2_simple_injected || null; - const dispatch_human_click = (target_el, options) => { + const dispatch_human_click = (target_el) => { const u = injected_utils(); - if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options); + if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el); return false; }; const mapping = { - EN: 'en_US', - ES: 'es_US', - AR: 'ar_AE', - DE: 'de_DE', - HE: 'he_IL', - KO: 'ko_KR', - PT: 'pt_BR', - ZH_CN: 'zh_CN', - ZH_TW: 'zh_TW', + EN: 'en_US', ES: 'es_US', AR: 'ar_AE', DE: 'de_DE', + HE: 'he_IL', KO: 'ko_KR', PT: 'pt_BR', ZH_CN: 'zh_CN', ZH_TW: 'zh_TW', }; const raw = params && params.lang != null ? String(params.lang).trim().toUpperCase() : 'ZH_CN'; const code = Object.prototype.hasOwnProperty.call(mapping, raw) ? raw : 'ZH_CN'; const switch_lang = mapping[code]; const href_sel = `a[href="#switch-lang=${switch_lang}"]`; const u = injected_utils(); + const deadline = Date.now() + 6000; let link = null; while (Date.now() < deadline) { @@ -199,25 +183,25 @@ export function injected_amazon_switch_language(params) { if (save) break; if (u && typeof u.busy_wait_ms === 'function') u.busy_wait_ms(40); } - if (save) { - dispatch_human_click(save); - } + if (save) dispatch_human_click(save); return { ok: true, lang: code }; } +// ──────────── 搜索列表(注入) ──────────── + export function injected_amazon_search_list(params) { const injected_utils = () => window.__mv2_simple_injected || null; - const dispatch_human_click = (target_el, options) => { + const dispatch_human_click = (target_el) => { const u = injected_utils(); - if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el, options); + if (u && typeof u.dispatch_human_click === 'function') return u.dispatch_human_click(target_el); return false; }; params = params && typeof params === 'object' ? params : {}; const debug = params.debug === true; const u = injected_utils(); - // validateCaptcha:在 onUpdated(complete) 钩子里也能自动处理 + // validateCaptcha 页面:直接点击继续 if ((location.href || '').includes('/errors/validateCaptcha')) { const btn = document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') || @@ -226,16 +210,13 @@ export function injected_amazon_search_list(params) { document.querySelector('input[type="submit"][value*="Continue"]') || document.querySelector('button[type="submit"]'); const clicked = btn ? dispatch_human_click(btn) : false; - if (debug) { - // eslint-disable-next-line no-console - console.log('[amazon][on_complete] validateCaptcha', { clicked, href: location.href }); - } + if (debug) console.log('[amazon][on_complete] validateCaptcha', { clicked, href: location.href }); return { stage: 'captcha', href: location.href, clicked }; } - const start_url = params && params.url ? String(params.url) : location.href; - const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : ''; - const sort_by = params && params.sort_by ? String(params.sort_by).trim() : ''; + const start_url = params.url ? String(params.url) : location.href; + const category_keyword = params.category_keyword ? String(params.category_keyword).trim() : ''; + const sort_by = params.sort_by ? String(params.sort_by).trim() : ''; const abs_url = u && typeof u.abs_url === 'function' ? u.abs_url : (x) => x; const parse_asin_from_url = u && typeof u.parse_asin_from_url === 'function' ? u.parse_asin_from_url : () => null; @@ -284,13 +265,8 @@ export function injected_amazon_search_list(params) { items.push({ index: idx + 1, asin: asin || parse_asin_from_url(item_url), - title, - url: item_url, - price, - rating, - rating_text, - review_count, - review_count_text, + title, url: item_url, price, + rating, rating_text, review_count, review_count_text, }); }); return items; @@ -299,30 +275,23 @@ export function injected_amazon_search_list(params) { function pick_next_url() { const a = document.querySelector('a.s-pagination-next'); if (!a) return null; - const aria_disabled = (a.getAttribute('aria-disabled') || '').trim().toLowerCase(); - if (aria_disabled === 'true') return null; + if ((a.getAttribute('aria-disabled') || '').trim().toLowerCase() === 'true') return null; if (a.classList && a.classList.contains('s-pagination-disabled')) return null; const href = a.getAttribute('href'); - if (!href) return null; - return abs_url(href); + return href ? abs_url(href) : null; } const items = extract_results(); const out = { start_url, href: location.href, category_keyword, sort_by, total: items.length, items, next_url: pick_next_url() }; if (debug) { - // eslint-disable-next-line no-console - console.log('[amazon][on_complete] search_list', { - href: out.href, - total: out.total, - has_next: !!out.next_url, - }); - try { - window.__amazon_debug_last_search_list = out; - } catch (_) { } + console.log('[amazon][on_complete] search_list', { href: out.href, total: out.total, has_next: !!out.next_url }); + try { window.__amazon_debug_last_search_list = out; } catch (_) {} } return out; } +// ──────────── 商品详情(注入) ──────────── + export function injected_amazon_product_detail() { const u = window.__mv2_simple_injected || null; const norm = u && typeof u.norm_space === 'function' ? u.norm_space : (s) => (s || '').replace(/\s+/g, ' ').trim(); @@ -331,9 +300,7 @@ export function injected_amazon_product_detail() { const product_info = {}; function set_info(k, v, max_len) { - k = norm(k); - v = norm(v); - max_len = max_len || 600; + k = norm(k); v = norm(v); max_len = max_len || 600; if (!k || !v || k.length > 100) return; if (v.length > max_len) v = v.slice(0, max_len); if (!product_info[k] || v.length > product_info[k].length) product_info[k] = v; @@ -392,25 +359,16 @@ export function injected_amazon_product_detail() { if (del) delivery_hint = norm(del.innerText).slice(0, 500); return { - stage: 'detail', - asin, - title, - price, - brand_line, - brand_store_url, - rating_stars, - review_count_text, - ac_badge, - social_proof, - bestseller_hint, - product_info, - detail_extra_lines, - bullets, - delivery_hint, + stage: 'detail', asin, title, price, + brand_line, brand_store_url, rating_stars, review_count_text, + ac_badge, social_proof, bestseller_hint, + product_info, detail_extra_lines, bullets, delivery_hint, url: location.href, }; } +// ──────────── 商品评论(注入) ──────────── + export function injected_amazon_product_reviews(params) { const raw = params && params.limit != null ? Number(params.limit) : 50; const limit = Number.isFinite(raw) ? Math.max(1, Math.min(100, Math.floor(raw))) : 50; @@ -435,6 +393,8 @@ export function injected_amazon_product_reviews(params) { return { stage: 'reviews', limit, total: items.length, items, url: location.href }; } +// ──────────── URL 工具 ──────────── + export function normalize_product_url(u) { let s = u ? String(u).trim() : ''; if (!s) throw new Error('缺少 product_url'); @@ -455,248 +415,15 @@ export function is_amazon_search_list_url(tab_url) { return tab_url.includes('k=') || tab_url.includes('keywords=') || tab_url.includes('field-keywords'); } -export function wait_until_search_list_url(tab_id, timeout_ms) { - const deadline = Date.now() + (timeout_ms || 45000); - return new Promise((resolve, reject) => { - const tick = () => { - chrome.tabs.get(tab_id, (tab) => { - if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); - const u = tab && tab.url ? tab.url : ''; - if (is_amazon_search_list_url(u)) return resolve(u); - if (Date.now() >= deadline) return reject(new Error('等待首页搜索跳转到列表页超时')); - setTimeout(tick, 400); - }); - }; - tick(); - }); -} - -const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo'; -const AMAZON_HOME_FOR_LANG = - 'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN¤cy=HKD'; - -export async function run_amazon_search_list_action(data, sendResponse) { - const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : ''; - const sort_by = data && data.sort_by ? String(data.sort_by).trim() : ''; - const keep_tab_open = data && data.keep_tab_open === true; - const limit = (() => { - const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100; - if (!Number.isFinite(n)) return 100; - return Math.max(1, Math.min(200, Math.floor(n))); - })(); - const keyword = category_keyword || 'picnic bag'; - const sort_map = { - featured: 'relevanceblender', - review: 'review-rank', - newest: 'date-desc-rank', - price_asc: 'price-asc-rank', - price_desc: 'price-desc-rank', - bestseller: 'exact-aware-popularity-rank', - }; - const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : ''; - const send_action = create_send_action(sendResponse); - const tab_task = create_tab_task(AMAZON_ZH_HOME_URL) - .set_latest(false) - .set_bounds({ top: 20, left: 20, width: 1440, height: 900 }) - .set_target('__amazon_search_list'); - let url = AMAZON_ZH_HOME_URL; - const tab = await tab_task.open_async().catch((err) => { - send_action('amazon_search_list', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url || AMAZON_ZH_HOME_URL, - })); - throw err; - }); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_search_list, [{ category_keyword, sort_by, debug: true }], 'document_idle'); - await try_solve_amazon_validate_captcha(tab, 3); - let home_ok = null; - for (let i = 0; i < 3; i += 1) { - const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle'); - home_ok = pick_first_script_result(home_ret); - if (home_ok && home_ok.ok) break; - await sleep_ms(400); - await tab.wait_complete(); - await try_solve_amazon_validate_captcha(tab, 1); - } - if (!home_ok || !home_ok.ok) { - const current_url = await get_tab_url(tab.id).catch(() => ''); - const detail = home_ok && typeof home_ok === 'object' - ? JSON.stringify(home_ok) - : String(home_ok); - throw new Error(`首页搜索提交失败: ${detail}; url=${current_url || 'unknown'}`); - } - url = await wait_until_search_list_url(tab.id, 45000); - await tab.wait_complete(); - if (sort_s) { - const u = new URL(url); - u.searchParams.set('s', sort_s); - url = u.toString(); - await tab.navigate(url); - await tab.wait_complete(); - } - const unique_map = new Map(); - let next_url = url; - for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) { - if (page > 1) { - await tab.navigate(next_url); - await tab.wait_complete(); - } - const injected_result_list = await tab.execute_script( - injected_amazon_search_list, - [{ url: next_url, category_keyword, sort_by }], - 'document_idle', - ); - const injected_result = pick_first_script_result(injected_result_list); - const items = injected_result && Array.isArray(injected_result.items) ? injected_result.items : []; - items.forEach((it) => { - const k = it && (it.asin || it.url) ? String(it.asin || it.url) : null; - if (!k) return; - if (!unique_map.has(k)) unique_map.set(k, it); - }); - if (unique_map.size >= limit) break; - next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null; - if (!next_url) break; - } - const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) }; - return { tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result }; - }).catch((err) => { - send_action('amazon_search_list', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url || AMAZON_ZH_HOME_URL, - })); - if (!keep_tab_open) tab.remove(0); - throw err; - }); - send_action('amazon_search_list', ok_response(payload)); - if (!keep_tab_open) tab.remove(0); - return payload; -} - -export async function run_amazon_set_language_action(data, sendResponse) { - const mapping = { - EN: 'en_US', - ES: 'es_US', - AR: 'ar_AE', - DE: 'de_DE', - HE: 'he_IL', - KO: 'ko_KR', - PT: 'pt_BR', - ZH_CN: 'zh_CN', - ZH_TW: 'zh_TW', - }; - const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN'; - const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN'; - const send_action = create_send_action(sendResponse); - const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG) - .set_latest(false) - .set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - const tab = await tab_task.open_async().catch((err) => { - send_action('amazon_set_language', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: AMAZON_HOME_FOR_LANG, - })); - throw err; - }); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle'); - const inj = pick_first_script_result(raw); - if (!inj || !inj.ok) { - throw new Error((inj && inj.error) || 'switch_language_failed'); - } - const final_url = await new Promise((resolve_url, reject_url) => { - chrome.tabs.get(tab.id, (tab_info) => { - if (chrome.runtime.lastError) return reject_url(new Error(chrome.runtime.lastError.message)); - resolve_url(tab_info && tab_info.url ? tab_info.url : ''); - }); - }); - return { tab_id: tab.id, lang: inj.lang, url: final_url }; - }).catch((err) => { - send_action('amazon_set_language', fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: AMAZON_HOME_FOR_LANG, - })); - throw err; - }); - send_action('amazon_set_language', ok_response(payload)); - tab.remove(0); - return payload; -} - -export async function run_amazon_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) { - const send_action = create_send_action(sendResponse); - const normalized = guard_sync(() => normalize_product_url(product_url)); - if (!normalized.ok) { - send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), { - code: response_code.bad_request, - })); - throw normalized.error; +/** + * 轮询等待 tab URL 变为搜索列表页(async 循环,替代旧版回调递归) + */ +export async function wait_until_search_list_url(tab_id, timeout_ms = 45000) { + const deadline = Date.now() + timeout_ms; + while (Date.now() < deadline) { + const url = await get_tab_url(tab_id); + if (is_amazon_search_list_url(url)) return url; + await sleep_ms(400); } - const url = normalized.data; - const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - const tab = await tab_task.open_async().catch((err) => { - send_action(action_name, fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); - throw err; - }); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - await try_solve_amazon_validate_captcha(tab, 3); - const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle'); - const result = pick_first_script_result(raw_list); - return { tab_id: tab.id, product_url: url, result }; - }).catch((err) => { - send_action(action_name, fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); - throw err; - }); - send_action(action_name, ok_response(payload)); - tab.remove(0); - return payload; -} - -export async function run_amazon_pdp_action_multi(product_url, steps, action_name, sendResponse) { - const send_action = create_send_action(sendResponse); - const normalized = guard_sync(() => normalize_product_url(product_url)); - if (!normalized.ok) { - send_action(action_name, fail_response((normalized.error && normalized.error.message) || String(normalized.error), { - code: response_code.bad_request, - })); - throw normalized.error; - } - const url = normalized.data; - const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); - const tab = await tab_task.open_async().catch((err) => { - send_action(action_name, fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); - throw err; - }); - const payload = await tab.wait_update_complete_once(async () => { - await tab.execute_script(injected_amazon_validate_captcha_continue, [], 'document_idle'); - await try_solve_amazon_validate_captcha(tab, 3); - const results = {}; - for (const step of steps || []) { - if (!step || !step.name || typeof step.injected_fn !== 'function') continue; - const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle'); - const result = pick_first_script_result(raw_list); - results[step.name] = result; - } - return { tab_id: tab.id, product_url: url, result: results }; - }).catch((err) => { - send_action(action_name, fail_response((err && err.message) || String(err), { - code: response_code.runtime_error, - documentURI: url, - })); - throw err; - }); - send_action(action_name, ok_response(payload)); - tab.remove(0); - return payload; + throw new Error('等待首页搜索跳转到列表页超时'); } diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js index ce21882..ecdefe1 100644 --- a/mv2_simple_crx/src/background/index.js +++ b/mv2_simple_crx/src/background/index.js @@ -1,9 +1,8 @@ - import { amazon_actions, getAllActionsMeta, getActionByName } from '../actions/index.js'; -// action 注册表:供 UI 下拉选择 + server bridge 调用 -let action_list = []; +// ──────────── Action 注册 ──────────── +let action_list = []; try { if (Array.isArray(amazon_actions)) { action_list = amazon_actions.filter(item => item && typeof item === 'object' && item.name); @@ -15,7 +14,28 @@ try { console.error('Failed to load amazon_actions:', error); } -const list_actions_meta = () => getAllActionsMeta(); +// ──────────── UI 事件推送 ──────────── + +const ui_page_url = chrome.runtime.getURL('ui/index.html'); + +const is_port_closed_error = (message) => { + const text = message ? String(message) : ''; + return text.includes('The message port closed before a response was received'); +}; + +const emit_ui_event = (event_name, payload) => { + try { + chrome.runtime.sendMessage({ channel: 'ui_event', event_name, payload, ts: Date.now() }, (response) => { + if (chrome.runtime.lastError) { + const err_msg = chrome.runtime.lastError.message; + if (is_port_closed_error(err_msg)) return; + console.warn('Failed to send UI event:', err_msg); + } + }); + } catch (error) { + console.error('Error in emit_ui_event:', error); + } +}; const create_action_send_response = (sender) => { const fn = (payload) => { @@ -27,135 +47,81 @@ const create_action_send_response = (sender) => { return fn; }; -const ui_page_url = chrome.runtime.getURL('ui/index.html'); +// ──────────── 内置 action 路由 ──────────── -const is_port_closed_error = (message) => { - const text = message ? String(message) : ''; - return text.includes('The message port closed before a response was received'); -}; - -const emit_ui_event = (event_name, payload) => { - try { - chrome.runtime.sendMessage({ - channel: 'ui_event', - event_name, - payload, - ts: Date.now(), - }, (response) => { - if (chrome.runtime.lastError) { - const err_msg = chrome.runtime.lastError.message; - // UI 页面未打开/已关闭时属于正常场景,不打印告警噪音 - if (is_port_closed_error(err_msg)) return; - console.warn('Failed to send UI event:', err_msg); - } - }); - } catch (error) { - console.error('Error in emit_ui_event:', error); - } -}; - -chrome.browserAction.onClicked.addListener(() => { - chrome.tabs.create({ url: ui_page_url, active: true }); -}); - -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - console.log('Received message:', { message, sender: sender.tab?.url || 'background' }); - - if (!message) { - console.warn('Empty message received'); - return; - } - - // UI 自己发出来的事件,background 不处理 - if (message.channel === 'ui_event') { - console.log('Ignoring ui_event message'); - return; - } - - // 内部:page world 执行结果回传 - if (message.channel === 'page_exec_bridge') { - return; - } - - // content -> background 的推送消息(通用) - if (message.type === 'push') { - console.log('Processing push message:', message.action); - emit_ui_event('push', { - type: 'push', - action: message.action, - data: message.data, - sender, - }); - return; - } - - // UI -> background 的 action 调用 - if (!message.action) { - console.error('Missing action in message'); - sendResponse && sendResponse({ ok: false, error: '缺少 action' }); - return; - } - - // UI 获取 action 元信息(用于下拉/默认参数) - if (message.action === 'meta_actions') { +const builtin_handlers = { + meta_actions(message, sender, sendResponse) { console.log('Returning actions meta'); - sendResponse({ ok: true, data: list_actions_meta() }); - return; - } - - // UI 刷新后台(重启 background page) - if (message.action === 'reload_background') { + sendResponse({ ok: true, data: getAllActionsMeta() }); + }, + reload_background(message, sender, sendResponse) { console.log('Reloading background page'); sendResponse({ ok: true }); - setTimeout(() => { - location.reload(); - }, 50); - return; - } + setTimeout(() => location.reload(), 50); + }, +}; - const action_item = getActionByName(message.action); - const action_handler = action_item && typeof action_item.handler === 'function' ? action_item.handler : null; - if (!action_handler) { - console.error('Unknown action:', message.action); - sendResponse({ ok: false, error: '未知 action: ' + message.action }); - return; - } +// ──────────── Action 执行器 ──────────── +async function execute_action(action_handler, message, sender, sendResponse) { const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`; console.log('Executing action:', { action: message.action, request_id, data: message.data }); emit_ui_event('request', { type: 'request', request_id, action: message.action, data: message.data || {}, sender }); const action_send_response = create_action_send_response(sender); - // 添加超时处理 - const timeout = setTimeout(() => { - console.warn(`Action ${message.action} timed out after 30000ms`); - emit_ui_event('response', { - type: 'response', - request_id, - ok: false, - error: 'Action timed out after 30 seconds', - sender - }); - sendResponse({ ok: false, error: 'Action timed out after 30 seconds', request_id }); - }, 30000); // 30秒超时 + try { + const res = await action_handler(message.data || {}, action_send_response); + console.log(`Action ${message.action} completed successfully:`, { request_id, result: res }); + emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender }); + sendResponse({ ok: true, data: res, request_id }); + } catch (err) { + const error = (err && err.message) || String(err); + const stack = (err && err.stack) || ''; + console.error(`Action ${message.action} failed:`, { error, stack, data: message.data }); + emit_ui_event('response', { type: 'response', request_id, ok: false, error, stack, sender }); + sendResponse({ ok: false, error, stack, request_id }); + } +} - (async () => { - try { - const res = await action_handler(message.data || {}, action_send_response); - clearTimeout(timeout); - console.log(`Action ${message.action} completed successfully:`, { request_id, result: res }); - emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender }); - sendResponse({ ok: true, data: res, request_id }); - } catch (err) { - clearTimeout(timeout); - const error = (err && err.message) || String(err); - const stack = (err && err.stack) || ''; - console.error(`Action ${message.action} failed:`, { error, stack, data: message.data }); - emit_ui_event('response', { type: 'response', request_id, ok: false, error, stack, sender }); - sendResponse({ ok: false, error, stack, request_id }); - } - })(); +// ──────────── 消息分发 ──────────── +chrome.browserAction.onClicked.addListener(() => { + chrome.tabs.create({ url: ui_page_url, active: true }); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // 忽略:空消息、UI 自身事件、page world 桥接 + if (!message || message.channel === 'ui_event' || message.channel === 'page_exec_bridge') return; + + // content -> background 推送 + if (message.type === 'push') { + console.log('Processing push message:', message.action); + emit_ui_event('push', { type: 'push', action: message.action, data: message.data, sender }); + return; + } + + // 缺少 action + if (!message.action) { + console.error('Missing action in message'); + sendResponse && sendResponse({ ok: false, error: '缺少 action' }); + return; + } + + // 内置 action(同步处理,不需要 return true) + if (builtin_handlers[message.action]) { + builtin_handlers[message.action](message, sender, sendResponse); + return; + } + + // 业务 action + const action_item = getActionByName(message.action); + if (!action_item || typeof action_item.handler !== 'function') { + console.error('Unknown action:', message.action); + sendResponse({ ok: false, error: '未知 action: ' + message.action }); + return; + } + + execute_action(action_item.handler, message, sender, sendResponse); return true; }); diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js index 7574692..2394bb7 100644 --- a/mv2_simple_crx/src/libs/tabs.js +++ b/mv2_simple_crx/src/libs/tabs.js @@ -1,357 +1,249 @@ -// openTab:MV2 版本(极简 + 回调风格) +// tabs.js:MV2 Tab 操作工具(Promise 风格) + +// ──────────── Chrome API Promise 封装 ──────────── + +function chrome_tabs_get(tab_id) { + return new Promise((resolve, reject) => { + chrome.tabs.get(tab_id, (t) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(t); + }); + }); +} + +function chrome_tabs_update(tab_id, props) { + return new Promise((resolve, reject) => { + chrome.tabs.update(tab_id, props, (t) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(t || true); + }); + }); +} + +function chrome_tabs_remove(tab_id) { + return new Promise((resolve, reject) => { + chrome.tabs.remove(tab_id, () => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(true); + }); + }); +} + +function chrome_tabs_create(opts) { + return new Promise((resolve, reject) => { + chrome.tabs.create(opts, (t) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(t); + }); + }); +} + +function chrome_windows_create(opts) { + return new Promise((resolve, reject) => { + chrome.windows.create(opts, (w) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(w); + }); + }); +} + +function chrome_tabs_execute_script(tab_id, details) { + return new Promise((resolve, reject) => { + chrome.tabs.executeScript(tab_id, details, (result) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(result); + }); + }); +} /** - * 构建可执行代码字符串 - * @param {Function|string} fn - 要执行的函数或代码字符串 - * @param {Array} args - 传递给函数的参数数组 - * @returns {string} 可执行的代码字符串 + * 等待 tab 进入 status=complete(含超时) + * 先检查当前状态,已 complete 则直接返回 */ +function wait_tab_status_complete(tab_id, timeout_ms = 45000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + reject(new Error('等待页面加载超时')); + }, timeout_ms); + const listener = (id, info, tab) => { + if (id !== tab_id || info.status !== 'complete') return; + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timer); + resolve(tab); + }; + chrome.tabs.onUpdated.addListener(listener); + }); +} + +// ──────────── 代码构建 ──────────── + const build_code = (fn, args) => { if (typeof fn === 'function') { - const funcStr = fn.toString(); + const func_str = fn.toString(); if (Array.isArray(args) && args.length > 0) { - // 安全地序列化参数,避免循环引用 - const serializedArgs = JSON.stringify(args, (key, value) => { + const serialized = JSON.stringify(args, (key, value) => { if (typeof value === 'function') return undefined; if (value && typeof value === 'object' && value.constructor === Object) { - try { - JSON.stringify(value); - return value; - } catch { - return '[Object]'; - } + try { JSON.stringify(value); return value; } catch { return '[Object]'; } } return value; }); - return `(${funcStr}).apply(null, ${serializedArgs});`; + return `(${func_str}).apply(null, ${serialized});`; } - return `(${funcStr})();`; + return `(${func_str})();`; } - - if (typeof fn === 'string') { - return fn; - } - + if (typeof fn === 'string') return fn; throw new TypeError('fn must be a function or string'); }; +// ──────────── 脚本执行(低阶) ──────────── + /** - * 在指定标签页中执行原始脚本(低阶接口,不做任何前置注入) - * @param {number} tab_id - 标签页ID - * @param {Function|string} fn - 要执行的函数或代码字符串 - * @param {Array} args - 传递给函数的参数数组 - * @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle' - * @returns {Promise} 执行结果数组 + * 在页面上下文执行脚本(page world 桥接) + * 通过 CustomEvent + chrome.runtime.onMessage 回传结果 */ export async function raw_execute_script(tab_id, fn, args = [], run_at = 'document_idle') { - // 参数验证 - if (!Number.isInteger(tab_id) || tab_id <= 0) { - throw new Error('Invalid tab_id: must be a positive integer'); - } + const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`; + const event_name = `__mv2_simple_page_exec_done__${request_id}`; - if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) { - throw new Error('Invalid fn: must be a function or string'); - } + const page_exec_stmt = typeof fn === 'function' + ? `__exec_result = ${build_code(fn, args)}` + : `__exec_result = (function () { ${fn} })();`; - if (!Array.isArray(args)) { - throw new Error('Invalid args: must be an array'); - } - - const validRunAt = ['document_start', 'document_end', 'document_idle']; - if (!validRunAt.includes(run_at)) { - throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); - } - - try { - const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`; - const event_name = `__mv2_simple_page_exec_done__${request_id}`; - - // 页面上下文执行:用事件把结果从 page world 回传到 extension world - const page_exec_stmt = (() => { - if (typeof fn === 'function') { - // build_code(fn, args) 返回一段“可执行并产生值”的代码(末尾可能带分号) - return `__exec_result = ${build_code(fn, args)}`; - } - // fn 为 string:包进 IIFE,允许用户在字符串里使用 return 返回值 - return `__exec_result = (function () { ${fn} })();`; - })(); - - const page_script_text = ` - (function () { - const __request_id = ${JSON.stringify(request_id)}; - const __event_name = ${JSON.stringify(event_name)}; - let __exec_result; - Promise.resolve() - .then(() => { - ${page_exec_stmt} - return __exec_result; - }) - .then((__result) => { - window.dispatchEvent(new CustomEvent(__event_name, { - detail: { request_id: __request_id, ok: true, result: __result } - })); - }) - .catch((__err) => { - const __e = __err; - window.dispatchEvent(new CustomEvent(__event_name, { - detail: { - request_id: __request_id, - ok: false, - error: { - message: (__e && __e.message) ? __e.message : String(__e), - stack: (__e && __e.stack) ? __e.stack : '' - } - } - })); - }); - })(); - `.trim(); - - const bootstrap_code = ` - (function () { - const __request_id = ${JSON.stringify(request_id)}; - const __event_name = ${JSON.stringify(event_name)}; - const __on_done = (ev) => { - const detail = ev && ev.detail ? ev.detail : null; - if (!detail || detail.request_id !== __request_id) return; - window.removeEventListener(__event_name, __on_done, true); - try { - chrome.runtime.sendMessage({ - channel: 'page_exec_bridge', + const page_script_text = ` + (function () { + const __request_id = ${JSON.stringify(request_id)}; + const __event_name = ${JSON.stringify(event_name)}; + let __exec_result; + Promise.resolve() + .then(() => { + ${page_exec_stmt} + return __exec_result; + }) + .then((__result) => { + window.dispatchEvent(new CustomEvent(__event_name, { + detail: { request_id: __request_id, ok: true, result: __result } + })); + }) + .catch((__err) => { + window.dispatchEvent(new CustomEvent(__event_name, { + detail: { request_id: __request_id, - ok: !!detail.ok, - result: detail.result, - error_message: detail.error && detail.error.message ? detail.error.message : null, - error_stack: detail.error && detail.error.stack ? detail.error.stack : null - }); - } catch (_) { - // ignore - } - }; - window.addEventListener(__event_name, __on_done, true); - const el = document.createElement('script'); - el.type = 'text/javascript'; - el.textContent = ${JSON.stringify(page_script_text)}; - (document.head || document.documentElement).appendChild(el); - el.parentNode && el.parentNode.removeChild(el); - })(); - `.trim(); + ok: false, + error: { + message: (__err && __err.message) ? __err.message : String(__err), + stack: (__err && __err.stack) ? __err.stack : '' + } + } + })); + }); + })(); + `.trim(); - return await new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - chrome.runtime.onMessage.removeListener(on_message); - reject(new Error(`Script execution timeout for tab ${tab_id}`)); - }, 30000); // 30秒超时 - - const on_message = (message) => { - if (!message || message.channel !== 'page_exec_bridge' || message.request_id !== request_id) return; - clearTimeout(timeoutId); - chrome.runtime.onMessage.removeListener(on_message); - if (message.ok) return resolve([message.result]); - const err = new Error(message.error_message || 'page script execution failed'); - err.stack = message.error_stack || err.stack; - return reject(err); + const bootstrap_code = ` + (function () { + const __request_id = ${JSON.stringify(request_id)}; + const __event_name = ${JSON.stringify(event_name)}; + const __on_done = (ev) => { + const detail = ev && ev.detail ? ev.detail : null; + if (!detail || detail.request_id !== __request_id) return; + window.removeEventListener(__event_name, __on_done, true); + try { + chrome.runtime.sendMessage({ + channel: 'page_exec_bridge', + request_id: __request_id, + ok: !!detail.ok, + result: detail.result, + error_message: detail.error && detail.error.message ? detail.error.message : null, + error_stack: detail.error && detail.error.stack ? detail.error.stack : null + }); + } catch (_) {} }; + window.addEventListener(__event_name, __on_done, true); + const el = document.createElement('script'); + el.type = 'text/javascript'; + el.textContent = ${JSON.stringify(page_script_text)}; + (document.head || document.documentElement).appendChild(el); + el.parentNode && el.parentNode.removeChild(el); + })(); + `.trim(); - chrome.runtime.onMessage.addListener(on_message); + // 同时监听 onMessage 回传 + executeScript 报错,无法再简化 + return await new Promise((resolve, reject) => { + const timeout_id = setTimeout(() => { + chrome.runtime.onMessage.removeListener(on_message); + reject(new Error(`Script execution timeout for tab ${tab_id}`)); + }, 30000); - chrome.tabs.executeScript( - tab_id, - { - code: bootstrap_code, - runAt: run_at, - }, - () => { - if (chrome.runtime.lastError) { - clearTimeout(timeoutId); - chrome.runtime.onMessage.removeListener(on_message); - const error = new Error(chrome.runtime.lastError.message); - error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'EXECUTION_ERROR'; - reject(error); - } - } - ); - }); - } catch (error) { - // 重新抛出带有更多上下文的错误 - const enhancedError = new Error(`Failed to execute script in tab ${tab_id}: ${error.message}`); - enhancedError.originalError = error; - enhancedError.tabId = tab_id; - enhancedError.runAt = run_at; - throw enhancedError; - } -} + const on_message = (message) => { + if (!message || message.channel !== 'page_exec_bridge' || message.request_id !== request_id) return; + clearTimeout(timeout_id); + chrome.runtime.onMessage.removeListener(on_message); + if (message.ok) return resolve([message.result]); + const err = new Error(message.error_message || 'page script execution failed'); + err.stack = message.error_stack || err.stack; + return reject(err); + }; -/** - * 在指定标签页中注入文件 - * @param {number} tab_id - 标签页ID - * @param {string} file - 要注入的文件路径(相对于扩展根目录) - * @param {string} run_at - 执行时机:'document_start' | 'document_end' | 'document_idle' - * @returns {Promise} 注入是否成功 - */ -export async function inject_file(tab_id, file, run_at = 'document_idle') { - // 参数验证 - if (!Number.isInteger(tab_id) || tab_id <= 0) { - throw new Error('Invalid tab_id: must be a positive integer'); - } + chrome.runtime.onMessage.addListener(on_message); - if (!file || typeof file !== 'string') { - throw new Error('Invalid file: must be a non-empty string'); - } - - // 验证文件路径格式 - if (!file.match(/^[\w\-./]+$/)) { - throw new Error('Invalid file path: contains invalid characters'); - } - - const validRunAt = ['document_start', 'document_end', 'document_idle']; - if (!validRunAt.includes(run_at)) { - throw new Error(`Invalid run_at: must be one of ${validRunAt.join(', ')}`); - } - - try { - return await new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`File injection timeout for tab ${tab_id}: ${file}`)); - }, 15000); // 15秒超时 - - chrome.tabs.executeScript( - tab_id, - { - file, - runAt: run_at, - }, - (result) => { - clearTimeout(timeoutId); - - if (chrome.runtime.lastError) { - const error = new Error(chrome.runtime.lastError.message); - error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : - chrome.runtime.lastError.message?.includes('Cannot access') ? 'FILE_NOT_FOUND' : 'INJECTION_ERROR'; - error.file = file; - return reject(error); - } - - resolve(true); - } - ); - }); - } catch (error) { - // 重新抛出带有更多上下文的错误 - const enhancedError = new Error(`Failed to inject file "${file}" in tab ${tab_id}: ${error.message}`); - enhancedError.originalError = error; - enhancedError.tabId = tab_id; - enhancedError.file = file; - enhancedError.runAt = run_at; - throw enhancedError; - } -} - -/** - * 确保注入脚本已加载到指定标签页 - * @param {number} tab_id - 标签页ID - * @param {number} maxRetries - 最大重试次数(默认3次) - * @returns {Promise} 注入是否成功 - */ -export async function ensure_injected(tab_id, maxRetries = 3) { - if (!Number.isInteger(tab_id) || tab_id <= 0) { - throw new Error('Invalid tab_id: must be a positive integer'); - } - - // 检查是否已经注入 - try { - const injected_frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'); - const injected = Array.isArray(injected_frames) && injected_frames.length ? (injected_frames[0]?.result ?? injected_frames[0]) : null; - if (injected === true) return true; - } catch (error) { - // 如果检查失败,可能是标签页不存在,继续尝试注入 - console.warn(`Failed to check injection status for tab ${tab_id}:`, error.message); - } - - // 尝试注入,带重试机制 - let lastError; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // 约定:扩展根目录=src,因此 file 使用 src 内相对路径 - await inject_file(tab_id, 'injected/injected.js', 'document_idle'); - - // 验证注入是否成功 - const injected_frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'); - const injected = Array.isArray(injected_frames) && injected_frames.length ? (injected_frames[0]?.result ?? injected_frames[0]) : null; - - if (injected === true) return true; - - // 如果注入后仍然失败,等待一小段时间再重试 - if (attempt < maxRetries) { - await new Promise(resolve => setTimeout(resolve, 500 * attempt)); - } - } catch (error) { - lastError = error; - if (attempt < maxRetries) { - await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); - } - } - } - - throw new Error(`Failed to ensure injection after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); -} - -/** - * 高阶脚本执行接口(默认确保 injected 通用方法已加载) - * @param {number} tab_id - 标签页ID - * @param {Function|string} fn - 要执行的函数或代码字符串 - * @param {Array} args - 传递给函数的参数数组 - * @param {string} run_at - 执行时机 - * @param {Object} options - 选项配置 - * @param {boolean} options.ensure_injected - 是否确保注入(默认true) - * @param {number} options.maxRetries - 注入重试次数(默认3次) - * @returns {Promise} 执行结果数组 - */ -export async function execute_script(tab_id, fn, args = [], run_at = 'document_idle', options = {}) { - // 参数验证 - if (!Number.isInteger(tab_id) || tab_id <= 0) { - throw new Error('Invalid tab_id: must be a positive integer'); - } - - if (!fn || (typeof fn !== 'function' && typeof fn !== 'string')) { - throw new Error('Invalid fn: must be a function or string'); - } - - // 选项配置 - const opts = { - ensure_injected: true, - maxRetries: 3, - ...options - }; - - try { - // 确保注入(如果需要) - if (opts.ensure_injected) { - await ensure_injected(tab_id, opts.maxRetries); - } - - // 执行脚本 - return await raw_execute_script(tab_id, fn, args, run_at); - } catch (error) { - // 增强错误信息 - const enhancedError = new Error(`Failed to execute script in tab ${tab_id}: ${error.message}`); - enhancedError.originalError = error; - enhancedError.tabId = tab_id; - enhancedError.ensureInjected = opts.ensure_injected; - throw enhancedError; - } -} - -const update_tab = (tab_id, update_props) => { - return new Promise((resolve_update, reject_update) => { - chrome.tabs.update(tab_id, update_props, (updated_tab) => { + chrome.tabs.executeScript(tab_id, { code: bootstrap_code, runAt: run_at }, () => { if (chrome.runtime.lastError) { - return reject_update(new Error(chrome.runtime.lastError.message)); + clearTimeout(timeout_id); + chrome.runtime.onMessage.removeListener(on_message); + reject(new Error(chrome.runtime.lastError.message)); } - resolve_update(updated_tab || true); }); }); -}; +} + +// ──────────── 注入文件 ──────────── + +export async function inject_file(tab_id, file, run_at = 'document_idle') { + await chrome_tabs_execute_script(tab_id, { file, runAt: run_at }); + return true; +} + +// ──────────── 确保 injected.js 已加载 ──────────── + +export async function ensure_injected(tab_id, max_retries = 3) { + // 先检查是否已注入 + try { + const frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'); + const injected = Array.isArray(frames) && frames.length ? (frames[0]?.result ?? frames[0]) : null; + if (injected === true) return true; + } catch (_) { + // 检查失败时继续尝试注入 + } + + let last_error; + for (let i = 1; i <= max_retries; i += 1) { + try { + await inject_file(tab_id, 'injected/injected.js', 'document_idle'); + const frames = await raw_execute_script(tab_id, () => !!window.__mv2_simple_injected, [], 'document_idle'); + const injected = Array.isArray(frames) && frames.length ? (frames[0]?.result ?? frames[0]) : null; + if (injected === true) return true; + if (i < max_retries) await new Promise((r) => setTimeout(r, 500 * i)); + } catch (err) { + last_error = err; + if (i < max_retries) await new Promise((r) => setTimeout(r, 1000 * i)); + } + } + throw new Error(`注入失败(重试 ${max_retries} 次): ${last_error?.message || 'unknown'}`); +} + +// ──────────── 高阶脚本执行 ──────────── + +export async function execute_script(tab_id, fn, args = [], run_at = 'document_idle', options = {}) { + const opts = { ensure_injected: true, max_retries: 3, ...options }; + if (opts.ensure_injected) { + await ensure_injected(tab_id, opts.max_retries); + } + return await raw_execute_script(tab_id, fn, args, run_at); +} + +// ──────────── Tab 辅助方法绑定 ──────────── const attach_tab_helpers = (tab) => { if (!tab) return tab; @@ -364,88 +256,45 @@ const attach_tab_helpers = (tab) => { }, Math.max(0, delay_ms)); }; - tab.execute_script = async function execute_script_on_tab(fn, args, run_at) { - return await execute_script(tab.id, fn, args, run_at); + tab.execute_script = (fn, args, run_at) => execute_script(tab.id, fn, args, run_at); + tab.inject_file = (file, run_at) => inject_file(tab.id, file, run_at); + tab.ensure_injected = () => ensure_injected(tab.id); + + tab.navigate = async (url, options) => { + const active = options && options.active === true; + return await chrome_tabs_update(tab.id, { url: String(url), active }); }; - tab.inject_file = async function inject_file_on_tab(file, run_at) { - return await inject_file(tab.id, file, run_at); - }; - - tab.ensure_injected = async function ensure_injected_on_tab() { - return await ensure_injected(tab.id); - }; - - tab.navigate = async function navigate(url, options) { - const nav_options = options && typeof options === 'object' ? options : {}; - const active = Object.prototype.hasOwnProperty.call(nav_options, 'active') ? nav_options.active === true : true; - const update_props = { url: String(url), active }; - return await update_tab(tab.id, update_props); - }; - - /** - * 等待 tab 页面加载完成(status=complete) - * - 作为 tab 方法,避免业务层到处传 tab_id - */ - tab.wait_complete = function wait_complete(timeout_ms) { + tab.wait_complete = async function wait_complete(timeout_ms) { const timeout = Number.isFinite(timeout_ms) ? Math.max(0, timeout_ms) : 45000; - return new Promise((resolve_wait, reject_wait) => { - chrome.tabs.get(tab.id, (tab0) => { - if (!chrome.runtime.lastError && tab0 && tab0.status === 'complete') { - return resolve_wait(tab0); - } - const on_updated = (updated_tab_id, change_info, updated_tab) => { - if (updated_tab_id !== tab.id) return; - if (!change_info || change_info.status !== 'complete') return; - chrome.tabs.onUpdated.removeListener(on_updated); - resolve_wait(updated_tab || true); - }; - chrome.tabs.onUpdated.addListener(on_updated); - setTimeout(() => { - chrome.tabs.onUpdated.removeListener(on_updated); - reject_wait(new Error('等待页面加载超时')); - }, timeout); - }); - }); + const t0 = await chrome_tabs_get(tab.id).catch(() => null); + if (t0 && t0.status === 'complete') return t0; + return await wait_tab_status_complete(tab.id, timeout); }; - /** - * 你期望的风格:tab.on_update_complete(() => tab.execute_script(...)) - * - 每次页面刷新/导航完成(status=complete) 都会触发回调 - * - 回调里可直接使用 tab.execute_script(而不是外部注入器封装) - */ tab._on_update_complete_listener = null; + tab.on_update_complete = function on_update_complete(fn, options) { - if (typeof fn !== 'function') return false; - if (!tab.id) return false; + if (typeof fn !== 'function' || !tab.id) return false; tab.off_update_complete && tab.off_update_complete(); let running = false; const once = !!(options && options.once === true); + const listener = async (updated_tab_id, change_info, updated_tab) => { - if (updated_tab_id !== tab.id) return; - if (!change_info || change_info.status !== 'complete') return; + if (updated_tab_id !== tab.id || !change_info || change_info.status !== 'complete') return; if (running) return; running = true; const tab_obj = attach_tab_helpers(updated_tab || tab); - // try { - debugger await fn(tab_obj, change_info); - if (once) { - tab.off_update_complete && tab.off_update_complete(); - } - // } catch (err) { - // eslint-disable-next-line no-console - // console.warn('[tab_on_update] fail', { tab_id: tab.id, error: (err && err.message) || String(err) }); - // } finally { + if (once) tab.off_update_complete && tab.off_update_complete(); running = false; - // } }; chrome.tabs.onUpdated.addListener(listener); tab._on_update_complete_listener = listener; - // 注册时如果已 complete,立即触发一次,保证首屏也能执行注入 + // 注册时如果已 complete,立即触发一次 chrome.tabs.get(tab.id, (t0) => { if (chrome.runtime.lastError) return; if (t0 && t0.status === 'complete') { @@ -455,21 +304,21 @@ const attach_tab_helpers = (tab) => { return true; }; - // 等待一次 on_update_complete 并返回 worker 结果。 tab.wait_update_complete_once = function wait_update_complete_once(worker) { return new Promise((resolve, reject) => { tab.on_update_complete(async () => { - const output = await worker(tab); - resolve(output); - }, { once: true, on_error: reject }); + try { + resolve(await worker(tab)); + } catch (err) { + reject(err); + } + }, { once: true }); }); }; tab.off_update_complete = function off_update_complete() { if (!tab._on_update_complete_listener) return; - try { - chrome.tabs.onUpdated.removeListener(tab._on_update_complete_listener); - } catch (_) { } + try { chrome.tabs.onUpdated.removeListener(tab._on_update_complete_listener); } catch (_) {} tab._on_update_complete_listener = null; }; @@ -484,183 +333,33 @@ const attach_tab_helpers = (tab) => { }; return tab; -} +}; + +// ──────────── 打开标签页 ──────────── -/** - * 打开新标签页并等待加载完成 - * @param {string} url - 要打开的URL - * @param {Object} options - 选项配置 - * @param {boolean} options.active - 是否激活标签页(默认true) - * @param {number} options.timeout - 加载超时时间(毫秒,默认45000) - * @param {boolean} options.loadInBackground - 是否在后台加载(默认false) - * @returns {Promise<{tab_id: number, tab: Object}>} 标签页信息 - */ export async function open_tab(url, options = {}) { - // 参数验证 - if (!url || typeof url !== 'string') { - throw new Error('Invalid url: must be a non-empty string'); - } - - // 验证URL格式 - try { - new URL(url); - } catch { - throw new Error('Invalid url format: must be a valid URL'); - } - - // 选项配置 - const opts = { - active: true, - timeout: 45000, // 45秒超时 - loadInBackground: false, - ...options - }; - - if (typeof opts.active !== 'boolean') { - throw new Error('Invalid options.active: must be a boolean'); - } - - if (!Number.isInteger(opts.timeout) || opts.timeout <= 0) { - throw new Error('Invalid options.timeout: must be a positive integer'); - } - - try { - return await new Promise((resolve, reject) => { - // 设置超时 - const timeoutId = setTimeout(() => { - chrome.tabs.onUpdated.removeListener(on_updated); - reject(new Error(`Tab loading timeout for ${url} after ${opts.timeout}ms`)); - }, opts.timeout); - - const on_updated = (updated_tab_id, change_info, updated_tab) => { - if (updated_tab_id !== tab_id) return; - if (change_info.status !== 'complete') return; - - clearTimeout(timeoutId); - chrome.tabs.onUpdated.removeListener(on_updated); - - try { - const enhancedTab = attach_tab_helpers(updated_tab); - resolve({ tab_id, tab: enhancedTab }); - } catch (error) { - reject(new Error(`Failed to attach helpers to tab ${tab_id}: ${error.message}`)); - } - }; - - chrome.tabs.onUpdated.addListener(on_updated); - - // 创建标签页 - chrome.tabs.create( - { - url: 'about:blank', - active: !opts.loadInBackground && opts.active, - }, - async (tab) => { - if (chrome.runtime.lastError) { - clearTimeout(timeoutId); - chrome.tabs.onUpdated.removeListener(on_updated); - const error = new Error(chrome.runtime.lastError.message); - error.code = 'TAB_CREATE_FAILED'; - return reject(error); - } - - if (!tab || !tab.id) { - clearTimeout(timeoutId); - chrome.tabs.onUpdated.removeListener(on_updated); - return reject(new Error('Failed to create tab: invalid tab object')); - } - - const tab_id = tab.id; - - try { - // 导航到目标URL - await update_tab(tab_id, { url }); - } catch (error) { - clearTimeout(timeoutId); - chrome.tabs.onUpdated.removeListener(on_updated); - reject(new Error(`Failed to navigate tab ${tab_id} to ${url}: ${error.message}`)); - } - } - ); - }); - } catch (error) { - // 增强错误信息 - const enhancedError = new Error(`Failed to open tab for ${url}: ${error.message}`); - enhancedError.originalError = error; - enhancedError.url = url; - throw enhancedError; - } + const opts = { active: true, timeout: 45000, loadInBackground: false, ...options }; + const tab = await chrome_tabs_create({ + url: 'about:blank', + active: !opts.loadInBackground && opts.active, + }); + if (!tab || !tab.id) throw new Error('创建标签页失败'); + await chrome_tabs_update(tab.id, { url }); + const done_tab = await wait_tab_status_complete(tab.id, opts.timeout); + return { tab_id: tab.id, tab: attach_tab_helpers(done_tab) }; } -/** - * 关闭指定标签页 - * @param {number} tab_id - 标签页ID - * @param {number|Object} delayOrOptions - 延迟时间(毫秒)或选项对象 - * @param {number} delayOrOptions.delay - 延迟时间(毫秒,默认0) - * @param {boolean} delayOrOptions.force - 是否强制关闭(默认false) - * @returns {Promise} 关闭是否成功 - */ -export async function close_tab(tab_id, delayOrOptions = {}) { - // 参数验证 - if (!Number.isInteger(tab_id) || tab_id <= 0) { - throw new Error('Invalid tab_id: must be a positive integer'); - } - - // 处理选项参数 - let options = {}; - if (typeof delayOrOptions === 'number') { - options.delay = delayOrOptions; - } else if (typeof delayOrOptions === 'object') { - options = delayOrOptions; - } - - const opts = { - delay: 0, - force: false, - ...options - }; - - if (!Number.isInteger(opts.delay) || opts.delay < 0) { - throw new Error('Invalid delay: must be a non-negative integer'); - } - - try { - return await new Promise((resolve, reject) => { - const delay = Math.max(0, opts.delay); - - if (delay === 0) { - // 立即关闭 - chrome.tabs.remove(tab_id, () => { - if (chrome.runtime.lastError) { - const error = new Error(chrome.runtime.lastError.message); - error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'CLOSE_FAILED'; - return reject(error); - } - resolve(true); - }); - } else { - // 延迟关闭 - setTimeout(() => { - chrome.tabs.remove(tab_id, () => { - if (chrome.runtime.lastError && !opts.force) { - const error = new Error(chrome.runtime.lastError.message); - error.code = chrome.runtime.lastError.message?.includes('No tab with id') ? 'TAB_NOT_FOUND' : 'CLOSE_FAILED'; - return reject(error); - } - resolve(true); - }); - }, delay); - } - }); - } catch (error) { - const enhancedError = new Error(`Failed to close tab ${tab_id}: ${error.message}`); - enhancedError.originalError = error; - enhancedError.tabId = tab_id; - throw enhancedError; +// ──────────── 关闭标签页 ──────────── + +export async function close_tab(tab_id, delay_ms = 0) { + if (delay_ms > 0) { + await new Promise((r) => setTimeout(r, delay_ms)); } + return await chrome_tabs_remove(tab_id); } -// openTab 任务对象:用对象绑定方法,减少重复参数 +// ──────────── Tab 任务对象 ──────────── + export function create_tab_task(url) { const task = { url, @@ -671,70 +370,38 @@ export function create_tab_task(url) { height: 900, target: null, active: true, - // 你期望的写法:tab_task.on_updated = () => {} on_error: null, on_updated: null, + set_bounds(bounds) { bounds = bounds && typeof bounds === 'object' ? bounds : {}; - if (Object.prototype.hasOwnProperty.call(bounds, 'top')) this.top = bounds.top; - if (Object.prototype.hasOwnProperty.call(bounds, 'left')) this.left = bounds.left; - if (Object.prototype.hasOwnProperty.call(bounds, 'width')) this.width = bounds.width; - if (Object.prototype.hasOwnProperty.call(bounds, 'height')) this.height = bounds.height; - return this; - }, - set_target(target) { - this.target = target || null; - return this; - }, - set_latest(latest) { - this.latest = !!latest; - return this; - }, - set_active(active) { - this.active = active !== false; + if ('top' in bounds) this.top = bounds.top; + if ('left' in bounds) this.left = bounds.left; + if ('width' in bounds) this.width = bounds.width; + if ('height' in bounds) this.height = bounds.height; return this; }, + set_target(target) { this.target = target || null; return this; }, + set_latest(latest) { this.latest = !!latest; return this; }, + set_active(active) { this.active = active !== false; return this; }, + async open_async() { - // 用 chrome.windows.create 新开窗口承载 tab - const win = await new Promise((resolve, reject) => { - chrome.windows.create( - { - url: 'about:blank', - type: 'popup', - focused: true, - top: this.top, - left: this.left, - width: this.width, - height: this.height, - }, - (w) => { - if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); - resolve(w); - }, - ); + const win = await chrome_windows_create({ + url: 'about:blank', + type: 'popup', + focused: true, + top: this.top, + left: this.left, + width: this.width, + height: this.height, }); - const tab0 = win && win.tabs && win.tabs[0] ? win.tabs[0] : null; - if (!tab0 || !tab0.id) { - throw new Error('popup window 创建失败'); - } + if (!tab0 || !tab0.id) throw new Error('popup window 创建失败'); - await update_tab(tab0.id, { url: this.url, active: this.active !== false }); - - const tab_done = await new Promise((resolve) => { - const on_updated = (tab_id, change_info, tab) => { - - if (tab_id !== tab0.id) return; - if (change_info.status !== 'complete') return; - chrome.tabs.onUpdated.removeListener(on_updated); - resolve(tab); - }; - chrome.tabs.onUpdated.addListener(on_updated); - }); - - return attach_tab_helpers(tab_done); + await chrome_tabs_update(tab0.id, { url: this.url, active: this.active !== false }); + const done_tab = await wait_tab_status_complete(tab0.id); + return attach_tab_helpers(done_tab); }, }; - return task; } diff --git a/server/app.js b/server/app.js index a369e55..16d42b6 100644 --- a/server/app.js +++ b/server/app.js @@ -25,4 +25,4 @@ await start_all_cron_tasks(); app.listen(port); // eslint-disable-next-line no-console -console.log(`server listening on ${port}`); +console.log(`[${new Date().toLocaleString()}] server listening on ${port}`); diff --git a/server/config/database.js b/server/config/database.js index f6c71ca..b65dd27 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -15,11 +15,11 @@ export function get_sequelize_options() { ? (sql, timing_ms) => { if (cfg.crawler.log_sql_benchmark === true && typeof timing_ms === 'number') { // eslint-disable-next-line no-console - console.log('[sql]', { timing_ms, sql }); + console.log(`[${new Date().toLocaleString()}] [sql]`, { timing_ms, sql }); return; } // eslint-disable-next-line no-console - console.log('[sql]', sql); + console.log(`[${new Date().toLocaleString()}] [sql]`, sql); } : false, define: { diff --git a/server/scripts/db_sync.js b/server/scripts/db_sync.js index b4e4f27..21093cd 100644 --- a/server/scripts/db_sync.js +++ b/server/scripts/db_sync.js @@ -2,5 +2,5 @@ import { sequelize } from '../models/index.js'; await sequelize.sync({ alter: true }); // eslint-disable-next-line no-console -console.log('sync ok'); +console.log(`[${new Date().toLocaleString()}] sync ok`); await sequelize.close(); diff --git a/server/services/flows/amazon/amazon_search_detail_reviews_flow.js b/server/services/flows/amazon/amazon_search_detail_reviews_flow.js index 9d1e551..e65c510 100644 --- a/server/services/flows/amazon/amazon_search_detail_reviews_flow.js +++ b/server/services/flows/amazon/amazon_search_detail_reviews_flow.js @@ -186,6 +186,8 @@ export async function run_amazon_search_detail_reviews_flow(flow_payload) { + await sleep_ms(1000); + const list_payload = { category_keyword, limit }; if (sort_by) { list_payload.sort_by = sort_by; diff --git a/server/services/puppeteer/puppeteer_runner.js b/server/services/puppeteer/puppeteer_runner.js index 49fa1b1..d95e007 100644 --- a/server/services/puppeteer/puppeteer_runner.js +++ b/server/services/puppeteer/puppeteer_runner.js @@ -115,7 +115,7 @@ export async function invoke_extension_action(action_name, action_payload, optio const log_enabled = cfg.crawler.log_invoke_action; if (log_enabled) { // eslint-disable-next-line no-console - console.log('[invoke_extension_action] start', { + console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] start`, { action_name, has_payload: !!action_payload, keys: action_payload && typeof action_payload === 'object' ? Object.keys(action_payload).slice(0, 20) : [] @@ -178,14 +178,17 @@ export async function invoke_extension_action(action_name, action_payload, optio if (log_enabled) { // eslint-disable-next-line no-console - console.log('[invoke_extension_action] ok', { action_name, cost_ms: Date.now() - started_at }); + console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] ok`, { + action_name, + cost_ms: Date.now() - started_at + }); } return action_res; } catch (err) { if (log_enabled) { // eslint-disable-next-line no-console - console.log('[invoke_extension_action] fail', { + console.log(`[${new Date().toLocaleString()}] [invoke_extension_action] fail`, { action_name, cost_ms: Date.now() - started_at, error: (err && err.message) || String(err) diff --git a/server/services/schedule_loader.js b/server/services/schedule_loader.js index ab9b1bc..ca6e457 100644 --- a/server/services/schedule_loader.js +++ b/server/services/schedule_loader.js @@ -43,7 +43,7 @@ async function run_cron_task(task) { async function run_cron_task_with_guard(task_name, task) { if (running_task_name_set.has(task_name)) { // eslint-disable-next-line no-console - console.log('[cron] skip (already running)', { name: task_name }); + console.log(`[${new Date().toLocaleString()}] [cron] skip (already running)`, { name: task_name }); return; } @@ -51,7 +51,8 @@ async function run_cron_task_with_guard(task_name, task) { try { await run_cron_task(task); } catch (error) { - console.warn('[cron] error', { task_name, error }); + // eslint-disable-next-line no-console + console.warn(`[${new Date().toLocaleString()}] [cron] error`, { task_name, error }); } finally { running_task_name_set.delete(task_name); } @@ -66,13 +67,14 @@ export async function start_all_cron_tasks() { const job = cron.schedule(task.cron_expression, async () => { await run_cron_task_with_guard(task_name, task); }); - console.log('job', { task_name, }); + // eslint-disable-next-line no-console + console.log(`[${new Date().toLocaleString()}] job`, { task_name }); cron_jobs.push(job); if (run_now) { // 启动时额外立刻跑一次(仍走 guard,避免与 cron 触发撞车) // eslint-disable-next-line no-console - console.log('[cron] run_now', { task_name }); + console.log(`[${new Date().toLocaleString()}] [cron] run_now`, { task_name }); await run_cron_task_with_guard(task_name, task); } }