diff --git a/mv2_simple_crx/src/actions/amazon.js b/mv2_simple_crx/src/actions/amazon.js index 1675bc6..55630f9 100644 --- a/mv2_simple_crx/src/actions/amazon.js +++ b/mv2_simple_crx/src/actions/amazon.js @@ -1,513 +1,35 @@ -// Amazon:搜索列表 + 商品详情 + 评论(注入函数与 action 同文件,便于维护) +// Amazon:action(编排逻辑放这里),注入函数放 amazon_tool.js import { create_tab_task } from '../libs/tabs.js'; - -// ---------- 页面注入(仅依赖页面 DOM,勿引用本文件其它符号) ---------- - -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"]'); - 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"]'); - if (btn) { - btn.click(); - return { ok: true }; - } - const form = input.closest('form'); - if (form) { - form.submit(); - return { ok: true }; - } - return { ok: false, error: 'no_submit' }; -} - -/** 语言设置页:在 customer-preferences/edit 页面选择语言并点击 Save Changes - * 入参使用短码:EN / ES / AR / DE / HE / KO / PT / ZH_CN / ZH_TW - */ -export function injected_amazon_switch_language(params) { - 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 = 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 deadline = Date.now() + 6000; - let link = null; - while (Date.now() < deadline) { - link = document.querySelector(href_sel); - if (link) { - const r = link.getBoundingClientRect(); - if (r.width > 0 && r.height > 0) break; - } - const t0 = performance.now(); - while (performance.now() - t0 < 40) {} - } - if (!link) return { ok: false, error: 'lang_option_timeout', lang: code }; - link.click(); - - // 尝试点击「保存修改」按钮 - const save_deadline = Date.now() + 6000; - let save = null; - while (Date.now() < save_deadline) { - save = - document.querySelector('input[type="submit"][value*="Save"]') || - document.querySelector('input[type="submit"][aria-labelledby*="icp-save-button"]') || - document.querySelector('span.icp-save-button input[type="submit"]'); - if (save) break; - const t1 = performance.now(); - while (performance.now() - t1 < 40) {} - } - if (save) { - save.click(); - } - - return { ok: true, lang: code }; -} - -export function injected_amazon_search_list(params) { - 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() : ''; - - function pick_number(text) { - if (!text) return null; - const s = String(text).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim(); - const m = s.match(/(\d+(?:\.\d+)?)/); - return m ? Number(m[1]) : null; - } - - function pick_int(text) { - if (!text) return null; - const raw = String(text).replace(/\s+/g, ' ').trim(); - const u = raw.toUpperCase().replace(/,/g, ''); - const km = u.match(/([\d.]+)\s*K\b/); - if (km) return Math.round(parseFloat(km[1]) * 1000); - const mm = u.match(/([\d.]+)\s*M\b/); - if (mm) return Math.round(parseFloat(mm[1]) * 1000000); - const digits = raw.replace(/[^\d]/g, ''); - return digits ? Number(digits) : null; - } - - function abs_url(href) { - try { - return new URL(href, location.origin).toString(); - } catch (_) { - return href; - } - } - - function parse_asin_from_url(url) { - if (!url || typeof url !== 'string') return null; - const m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i); - return m ? m[1].toUpperCase() : null; - } - - function extract_results() { - const items = []; - const nodes = document.querySelectorAll('div.s-main-slot div[data-component-type="s-search-result"]'); - nodes.forEach((el, idx) => { - const asin = (el.getAttribute('data-asin') || '').trim() || null; - const title_el = el.querySelector('h2 span') || el.querySelector('h2'); - const title = title_el ? title_el.textContent.trim() : null; - const a = el.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]'); - const href = a ? a.getAttribute('href') : null; - const item_url = href ? abs_url(href) : null; - const price_el = el.querySelector('span.a-price > span.a-offscreen'); - const price = price_el ? price_el.textContent.trim() : null; - const reviews_block = el.querySelector('div[data-cy="reviews-block"]') || el; - const rating_text = (() => { - const t1 = reviews_block.querySelector('span.a-icon-alt'); - if (t1 && t1.textContent) return t1.textContent.trim(); - const t2 = reviews_block.querySelector('span.a-size-small.a-color-base[aria-hidden="true"]'); - if (t2 && t2.textContent) return t2.textContent.trim(); - return null; - })(); - const rating = (() => { - const n = pick_number(rating_text); - return Number.isFinite(n) ? n : null; - })(); - const review_count_text = (() => { - const a1 = reviews_block.querySelector('a[href*="#customerReviews"]'); - if (a1 && a1.textContent) return a1.textContent.trim(); - const a2 = reviews_block.querySelector( - 'a[aria-label*="rating"], a[aria-label*="ratings"], a[aria-label*="评级"], a[aria-label*="评价"]', - ); - if (a2 && a2.getAttribute('aria-label')) return a2.getAttribute('aria-label').trim(); - const s1 = reviews_block.querySelector('span.a-size-mini.puis-normal-weight-text'); - if (s1 && s1.textContent) return s1.textContent.trim(); - return null; - })(); - const review_count = (() => { - const n = pick_int(review_count_text); - return Number.isFinite(n) ? n : null; - })(); - 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, - }); - }); - return items; - } - - 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.classList && a.classList.contains('s-pagination-disabled')) return null; - const href = a.getAttribute('href'); - if (!href) return null; - return abs_url(href); - } - - const items = extract_results(); - return { - start_url, - href: location.href, - category_keyword, - sort_by, - total: items.length, - items, - next_url: pick_next_url(), - }; -} - -export function injected_amazon_product_detail() { - const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); - const asin_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i); - const asin = asin_match ? asin_match[1].toUpperCase() : null; - - const product_info = {}; - function set_info(k, v, max_len) { - 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; - } - - const table_roots = - '#productOverview_feature_div tr, #poExpander table tr, #productDetails_detailBullets_sections1 tr, ' + - '#productDetails_techSpec_section_1 tr, table.prodDetTable tr, #productFactsDesktopExpander tr, ' + - '#technicalSpecifications_feature_div table tr, #productDetails_db_sections tr'; - document.querySelectorAll(table_roots).forEach((tr) => { - const tds = tr.querySelectorAll('td'); - const th = tr.querySelector('th'); - const td = tr.querySelector('td'); - if (tds.length >= 2) set_info(tds[0].innerText, tds[1].innerText); - else if (th && td && th !== td) set_info(th.innerText, td.innerText); - }); - - const detail_extra_lines = []; - document.querySelectorAll('#detailBullets_feature_div li, #rpi-attribute-values_feature_div li').forEach((li) => { - const t = li.innerText.replace(/\u200f|\u200e/g, ' ').replace(/\s+/g, ' ').trim(); - const m = t.match(/^(.{1,80}?)\s*[::]\s*(.+)$/); - if (m) set_info(m[1], m[2], 1200); - else if (t.length > 8 && t.length < 800) detail_extra_lines.push(t); - }); - - const title_el = document.querySelector('#productTitle'); - const title = title_el ? norm(title_el.textContent) : null; - const price_el = - document.querySelector('#corePrice_feature_div .a-price .a-offscreen') || - document.querySelector('#tp_price_block_total_price_ww .a-offscreen') || - document.querySelector('#price .a-offscreen') || - document.querySelector('.reinventPricePriceToPayMargin .a-offscreen') || - document.querySelector('.a-price .a-offscreen'); - const price = price_el ? price_el.textContent.trim() : null; - - const brand_el = document.querySelector('#bylineInfo'); - const brand_line = brand_el ? norm(brand_el.textContent) : null; - const brand_store_url = document.querySelector('#bylineInfo a[href]')?.href || null; - - const rating_stars = document.querySelector('#acrPopover')?.getAttribute('title') || - document.querySelector('#averageCustomerReviews .a-icon-alt')?.textContent?.trim() || null; - const review_count_text = document.querySelector('#acrCustomerReviewText')?.textContent?.trim() || null; - - const ac_badge = norm(document.querySelector('#acBadge_feature_div')?.innerText) || null; - const social_proof = norm(document.querySelector('#socialProofingAsinFaceout_feature_div')?.innerText) || null; - const bestseller_hint = norm(document.querySelector('#zeitgeistBadge_feature_div')?.innerText)?.slice(0, 200) || null; - let sustainability_hint = null; - document.querySelectorAll('button, span.a-button-text, a').forEach((el) => { - const tx = norm(el.innerText); - if (!tx || tx.length > 90) return; - if ( - /\d+\s*个.*可持续发展|可持续发展特性/.test(tx) || - /\d+\s+sustainability features?/i.test(tx) - ) { - sustainability_hint = tx; - } - }); - - const bullets = []; - document.querySelectorAll('#feature-bullets ul li span.a-list-item').forEach((el) => { - const t = norm(el.textContent); - if (t) bullets.push(t); - }); - - /** 变体 id 后缀是否为颜色 / 尺寸(仅提取这两项,不收集其它维度) */ - function is_sku_color_key(k) { - const x = String(k).toLowerCase(); - return x === 'color' || x === 'color_name' || x.endsWith('_color_name'); - } - function is_sku_size_key(k) { - const x = String(k).toLowerCase(); - return x === 'size' || x === 'size_name' || x.endsWith('_size_name'); - } - - /** 变体维度:颜色 / 尺寸 各为选项列表 */ - const sku = { color: [], size: [] }; - - const twister_plus_root = document.querySelector('#twister-plus-desktop-twister-container'); - - if (twister_plus_root) { - const color_row = twister_plus_root.querySelector('#inline-twister-row-color_name'); - if (color_row) { - const seen_c = new Set(); - color_row.querySelectorAll('li').forEach((li) => { - const img = li.querySelector('img[alt]'); - if (!img) return; - const v = norm(img.getAttribute('alt')); - if (v && !seen_c.has(v)) { - seen_c.add(v); - sku.color.push(v); - } - }); - } - if (!sku.color.length) { - const dim = twister_plus_root.querySelector('#inline-twister-expanded-dimension-text-color_name'); - const v = dim && norm(dim.textContent); - if (v) sku.color.push(v); - } - const size_row = twister_plus_root.querySelector('#inline-twister-row-size_name'); - if (size_row) { - const seen_s = new Set(); - size_row.querySelectorAll('li').forEach((li) => { - const el = li.querySelector('.swatch-title-text-display, .swatch-title-text-single-line'); - const v = el ? norm(el.textContent) : null; - if (v && !seen_s.has(v)) { - seen_s.add(v); - sku.size.push(v); - } - }); - } - if (!sku.size.length) { - const dim = twister_plus_root.querySelector('#inline-twister-expanded-dimension-text-size_name'); - const v = dim && norm(dim.textContent); - if (v) sku.size.push(v); - } - } else { - let cur_color = null; - let cur_size = null; - document.querySelectorAll('[id^="variation_"]').forEach((block) => { - const key = block.id.replace(/^variation_/, '') || block.id; - if (!is_sku_color_key(key) && !is_sku_size_key(key)) return; - const sel = - block.querySelector('.selection') || - block.querySelector('.a-button-selected .a-button-text') || - block.querySelector('[class*="dropdown"]'); - if (!sel) return; - const v = norm(sel.textContent); - if (!v) return; - if (is_sku_color_key(key) && !cur_color) cur_color = v; - if (is_sku_size_key(key) && !cur_size) cur_size = v; - }); - document.querySelectorAll('div.inline-twister-row[id^="inline-twister-row-"]').forEach((row) => { - const id = row.id || ''; - const key = id.replace(/^inline-twister-row-/, '') || id; - if (!is_sku_color_key(key) && !is_sku_size_key(key)) return; - const selected = - row.querySelector('.a-button-selected .swatch-title-text-display') || - row.querySelector('.a-button-selected .a-button-text') || - row.querySelector('.a-button-selected'); - if (!selected) return; - const v = norm(selected.textContent); - if (!v) return; - if (is_sku_color_key(key) && !cur_color) cur_color = v; - if (is_sku_size_key(key) && !cur_size) cur_size = v; - }); - if (cur_color) sku.color.push(cur_color); - if (cur_size) sku.size.push(cur_size); - } - - let delivery_hint = null; - const del = document.querySelector( - '#deliveryBlockMessage, #mir-layout-DELIVERY_BLOCK-slot-PRIMARY_DELIVERY_MESSAGE_LARGE', - ); - if (del) delivery_hint = norm(del.innerText).slice(0, 500); - - const images = []; - const seen_img = new Set(); - function add_img(u) { - if (!u || seen_img.has(u)) return; - if (!/media-amazon|images-amazon|ssl-images/i.test(u)) return; - seen_img.add(u); - images.push(u.split('?')[0]); - } - const land = document.querySelector('#landingImage, #imgBlkFront'); - if (land) { - const dyn = land.getAttribute('data-a-dynamic-image'); - if (dyn) { - try { - const o = JSON.parse(dyn); - Object.keys(o).forEach(add_img); - } catch (_) {} - } - if (land.src) add_img(land.src); - } - document.querySelectorAll('#altImages img, #imageBlock_feature_div img, #ivImages img').forEach((img) => { - add_img(img.src || img.getAttribute('data-src')); - }); - - const main_image = images.length ? images[0] : land?.src || null; - - return { - stage: 'detail', - asin, - title, - price, - brand_line, - brand_store_url, - rating_stars, - review_count_text, - ac_badge, - social_proof, - bestseller_hint, - sustainability_hint, - product_info, - detail_extra_lines, - bullets, - delivery_hint, - sku, - images, - main_image, - 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; - const nodes = document.querySelectorAll('[data-hook="review"]'); - const items = []; - nodes.forEach((r) => { - if (items.length >= limit) return; - const author_el = r.querySelector('.a-profile-name'); - const author = author_el ? author_el.textContent.trim() : null; - const title_el = r.querySelector('[data-hook="review-title"]'); - const title = title_el ? title_el.innerText.replace(/\s+/g, ' ').trim() : null; - const body_el = r.querySelector('[data-hook="review-body"]'); - const body = body_el ? body_el.innerText.replace(/\s+/g, ' ').trim() : null; - const rating_el = r.querySelector('[data-hook="review-star-rating"]'); - const rating_text = rating_el ? rating_el.textContent.trim() : null; - const date_el = r.querySelector('[data-hook="review-date"]'); - const date = date_el ? date_el.textContent.trim() : null; - const cr = r.querySelector('[id^="customer_review-"]'); - const review_id = r.id || (cr && cr.id ? cr.id.replace('customer_review-', '') : null); - items.push({ index: items.length + 1, review_id, author, rating_text, title, date, body }); - }); - return { stage: 'reviews', limit, total: items.length, items, url: location.href }; -} - -// ---------- 后台:搜索列表 ---------- +import { + injected_amazon_homepage_search, + injected_amazon_product_detail, + injected_amazon_product_reviews, + injected_amazon_search_list, + injected_amazon_switch_language, + injected_amazon_validate_captcha_continue, + normalize_product_url, + try_solve_amazon_validate_captcha, + wait_tab_complete, + wait_until_search_list_url, +} from './amazon_tool.js'; const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo'; - -/** 英文搜索列表 URL 模板(与 language=en_US 一致,仅替换 k) */ -function build_amazon_search_url_en(keyword) { - const u = new URL('https://www.amazon.com/s'); - u.searchParams.set('k', keyword); - u.searchParams.set('language', 'en_US'); - u.searchParams.set('crid', '35M31MY4FQI'); - u.searchParams.set('sprefix', ',aps,398'); - u.searchParams.set('ref', 'nb_sb_ss_recent_1_0_recent'); - return u.toString(); -} - -function is_amazon_search_list_url(tab_url) { - if (!tab_url || typeof tab_url !== 'string') return false; - if (!tab_url.includes('amazon.com')) return false; - if (!/\/s(\?|\/)/.test(tab_url)) return false; - return tab_url.includes('k=') || tab_url.includes('keywords=') || tab_url.includes('field-keywords'); -} - -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(); - }); -} - -function wait_tab_complete(tab_id) { - 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) => { - if (updated_tab_id !== tab_id) return; - if (change_info.status !== 'complete') return; - chrome.tabs.onUpdated.removeListener(on_updated); - resolve_wait(true); - }; - chrome.tabs.onUpdated.addListener(on_updated); - setTimeout(() => { - chrome.tabs.onUpdated.removeListener(on_updated); - reject_wait(new Error('等待页面加载超时')); - }, 45000); - }); - }); -} +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) => { 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 search_url_custom = data && data.search_url ? String(data.search_url).trim() : ''; - const entry = data && data.entry ? String(data.entry).trim() : 'direct'; + const sort_map = { featured: 'relevanceblender', review: 'review-rank', @@ -517,82 +39,118 @@ export function amazon_search_list(data, sendResponse) { bestseller: 'exact-aware-popularity-rank', }; const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : ''; + const send_action = (action, payload) => { if (typeof sendResponse === 'function') { sendResponse({ action, data: payload }); sendResponse.log && sendResponse.log(payload); } }; + const tab_task = create_tab_task(AMAZON_ZH_HOME_URL) .set_latest(false) .set_bounds({ top: 20, left: 20, width: 1440, height: 900 }) .set_target('__amazon_search_list'); + let url = AMAZON_ZH_HOME_URL; try { const tab = await tab_task.open_async(); - await wait_tab_complete(tab.id); - 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 wait_tab_complete(tab.id); - 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(tab.id, { url, active: true }, () => { - if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message)); - resolve_nav(true); - }); - }); - await wait_tab_complete(tab.id); - } - 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(tab.id, { url: next_url, active: true }, () => { - if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message)); - resolve_nav(true); + let running = false; + let resolved = false; + + 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 wait_tab_complete(tab.id); + 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 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 = { - code: 0, - status: true, - message: 'ok', - data: { 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 }); - tab.remove(0); + }); } catch (err) { send_action('amazon_search_list', { code: 30, @@ -609,74 +167,61 @@ export function amazon_search_list(data, sendResponse) { amazon_search_list.desc = 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取'; amazon_search_list.params = { category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' }, - sort_by: { - type: 'string', - desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', - default: 'featured', - }, + sort_by: { type: 'string', desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' }, limit: { type: 'number', desc: '抓取数量上限(默认 100,最大 200)', default: 100 }, + keep_tab_open: { type: 'boolean', desc: '调试用:不自动关闭窗口,方便手动刷新观察轨迹', default: false }, }; -const AMAZON_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_set_language(data, sendResponse) { - const mapping = { - EN: 'en_US', - ES: 'es_US', - AR: 'ar_AE', - DE: 'de_DE', - HE: 'he_IL', - KO: 'ko_KR', - PT: 'pt_BR', - ZH_CN: 'zh_CN', - ZH_TW: 'zh_TW', - }; - const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN'; - const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN'; - const send_action = (action, payload) => { - if (typeof sendResponse === 'function') { - sendResponse({ action, data: payload }); - sendResponse.log && sendResponse.log(payload); - } - }; return new Promise(async (resolve, reject) => { + const mapping = { + EN: 'en_US', + ES: 'es_US', + AR: 'ar_AE', + DE: 'de_DE', + HE: 'he_IL', + KO: 'ko_KR', + PT: 'pt_BR', + ZH_CN: 'zh_CN', + ZH_TW: 'zh_TW', + }; + const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN'; + const code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN'; + + const send_action = (action, payload) => { + if (typeof sendResponse === 'function') { + sendResponse({ action, data: payload }); + sendResponse.log && sendResponse.log(payload); + } + }; + const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG) .set_latest(false) .set_bounds({ top: 20, left: 20, width: 1280, height: 900 }); try { const tab = await tab_task.open_async(); - await wait_tab_complete(tab.id); - 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'); - } - await wait_tab_complete(tab.id); - const final_url = await new Promise((res, rej) => { - chrome.tabs.get(tab.id, (t) => { - if (chrome.runtime.lastError) return rej(new Error(chrome.runtime.lastError.message)); - res(t && t.url ? t.url : ''); + tab.on_update_complete(async (t) => { + // 首次 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'); + 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) => { + 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: tab.id, lang: inj.lang, url: final_url }, - }; - send_action('amazon_set_language', result); - resolve({ tab_id: tab.id, lang: inj.lang, url: final_url }); - tab.remove(0); + const result = { code: 0, status: true, message: 'ok', data: { tab_id: t.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, - }); - reject(err); + send_action('amazon_set_language', { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: AMAZON_HOME_FOR_LANG }); + reject(err); } }); } @@ -684,26 +229,39 @@ export function amazon_set_language(data, sendResponse) { amazon_set_language.desc = 'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言'; amazon_set_language.params = { - lang: { - type: 'string', - desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', - default: 'ZH_CN', - }, + lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' }, }; -// ---------- 后台:商品详情 / 评论 ---------- +export function amazon_product_detail(data, sendResponse) { + return run_pdp_action(data && data.product_url, injected_amazon_product_detail, [], 'amazon_product_detail', sendResponse); +} -function normalize_product_url(u) { - let s = u ? String(u).trim() : ''; - if (!s) throw new Error('缺少 product_url'); - if (s.startsWith('//')) s = 'https:' + s; - if (!/^https?:\/\//i.test(s)) s = 'https://' + s; - const url_obj = new URL(s); - if (!url_obj.hostname.includes('amazon.')) throw new Error('product_url 需为亚马逊域名'); - if (!/\/dp\/[A-Z0-9]{10}/i.test(url_obj.pathname) && !/\/gp\/product\/[A-Z0-9]{10}/i.test(url_obj.pathname)) { - throw new Error('product_url 需包含 /dp/ASIN 或 /gp/product/ASIN'); +amazon_product_detail.desc = + 'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)'; +amazon_product_detail.params = { + product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' }, +}; + +export function amazon_product_reviews(data, sendResponse) { + const limit = data && data.limit != null ? Number(data.limit) : 50; + return run_pdp_action(data && data.product_url, injected_amazon_product_reviews, [{ limit }], 'amazon_product_reviews', sendResponse); +} + +amazon_product_reviews.desc = 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)'; +amazon_product_reviews.params = { + product_url: { type: 'string', desc: '商品详情页完整 URL', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' }, + limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 }, +}; + +export function amazon_product_detail_reviews(data, sendResponse) { + const limit = data && data.limit != null ? Number(data.limit) : 50; + const skip_detail = data && data.skip_detail === true; + const steps = []; + if (!skip_detail) { + steps.push({ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }); } - return url_obj.toString(); + steps.push({ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] }); + return run_pdp_action_multi(data && data.product_url, steps, 'amazon_product_detail_reviews', sendResponse); } function run_pdp_action(product_url, injected_fn, inject_args, action_name, sendResponse) { @@ -724,25 +282,17 @@ function run_pdp_action(product_url, injected_fn, inject_args, action_name, send 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(); - await wait_tab_complete(tab.id); - 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: tab.id, product_url: url, result }, - }); - resolve({ tab_id: tab.id, product_url: url, result }); - tab.remove(0); + 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'); + 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, - }); + send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url }); reject(err); } }); @@ -767,91 +317,32 @@ function run_pdp_action_multi(product_url, steps, action_name, sendResponse) { 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(); - await wait_tab_complete(tab.id); + 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 results = {}; - for (const step of steps || []) { - if (!step || !step.name || typeof step.injected_fn !== 'function') continue; - const raw_list = await tab.execute_script(step.injected_fn, step.inject_args || [], 'document_idle'); - const result = Array.isArray(raw_list) ? raw_list[0] : raw_list; - results[step.name] = result; - } + 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 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: tab.id, product_url: url, result: results }, - }); - resolve({ tab_id: tab.id, product_url: url, result: results }); - tab.remove(0); + 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, - }); + send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url }); reject(err); } }); } -export function amazon_product_detail(data, sendResponse) { - return run_pdp_action(data && data.product_url, injected_amazon_product_detail, [], 'amazon_product_detail', sendResponse); -} - -amazon_product_detail.desc = - 'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)'; -amazon_product_detail.params = { - product_url: { - type: 'string', - desc: '商品详情页完整 URL(含 /dp/ASIN)', - default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC', - }, -}; - -export function amazon_product_reviews(data, sendResponse) { - const limit = data && data.limit != null ? Number(data.limit) : 50; - return run_pdp_action( - data && data.product_url, - injected_amazon_product_reviews, - [{ limit }], - 'amazon_product_reviews', - sendResponse, - ); -} - -amazon_product_reviews.desc = 'Amazon 商品页买家评论(详情页 [data-hook=review],条数受页面展示限制)'; -amazon_product_reviews.params = { - product_url: { - type: 'string', - desc: '商品详情页完整 URL', - default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC', - }, - limit: { type: 'number', desc: '最多条数(默认 50,上限 100)', default: 50 }, -}; - -export function amazon_product_detail_reviews(data, sendResponse) { - const limit = data && data.limit != null ? Number(data.limit) : 50; - const skip_detail = data && data.skip_detail === true; - const steps = []; - if (!skip_detail) { - steps.push({ name: 'detail', injected_fn: injected_amazon_product_detail, inject_args: [] }); - } - steps.push({ name: 'reviews', injected_fn: injected_amazon_product_reviews, inject_args: [{ limit }] }); - - return run_pdp_action_multi(data && data.product_url, steps, 'amazon_product_detail_reviews', sendResponse); -} - amazon_product_detail_reviews.desc = 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)'; amazon_product_detail_reviews.params = { - product_url: { - type: 'string', - desc: '商品详情页完整 URL(含 /dp/ASIN)', - default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC', - }, + product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' }, limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 }, skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false }, }; diff --git a/mv2_simple_crx/src/actions/amazon_tool.js b/mv2_simple_crx/src/actions/amazon_tool.js new file mode 100644 index 0000000..107f878 --- /dev/null +++ b/mv2_simple_crx/src/actions/amazon_tool.js @@ -0,0 +1,479 @@ +// Amazon:注入函数 + action 实现(amazon.js 仅保留 action 壳) +// +// 约定: +// - injected_* 在页面上下文执行,只依赖 DOM +// - 每个 action 打开 tab 后,通过 tab.set_on_complete_inject 绑定 onUpdated(status=complete) 注入钩子 + +// ---------- 页面注入(仅依赖页面 DOM) ---------- + +function dispatch_human_click(target_el, options) { + const el = target_el; + if (!el) return false; + options = options && typeof options === 'object' ? options : {}; + const pointer_id = Number.isFinite(options.pointer_id) ? options.pointer_id : 1; + const pointer_type = options.pointer_type ? String(options.pointer_type) : 'mouse'; + + try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { } + try { el.focus && el.focus(); } catch (_) { } + + const rect = el.getBoundingClientRect(); + const ox = Number.isFinite(options.offset_x) ? options.offset_x : 0; + const oy = Number.isFinite(options.offset_y) ? options.offset_y : 0; + const x = Math.max(1, Math.floor(rect.left + rect.width / 2 + ox)); + const y = Math.max(1, Math.floor(rect.top + rect.height / 2 + oy)); + const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }; + + try { + if (typeof PointerEvent === 'function') { + el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 1 })); + el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: pointer_id, pointerType: pointer_type, isPrimary: true, buttons: 0 })); + } + } catch (_) { } + + el.dispatchEvent(new MouseEvent('mousemove', base)); + el.dispatchEvent(new MouseEvent('mouseover', base)); + el.dispatchEvent(new MouseEvent('mousedown', base)); + el.dispatchEvent(new MouseEvent('mouseup', base)); + el.dispatchEvent(new MouseEvent('click', base)); + return true; +} + +export function injected_amazon_validate_captcha_continue() { + const href = location.href || ''; + const is_captcha = href.includes('/errors/validateCaptcha'); + if (!is_captcha) return { ok: true, is_captcha: false, clicked: false, 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"]') || + document.querySelector('input[type="submit"][value*="Continue"]') || + document.querySelector('button[type="submit"]'); + + const clicked = btn ? dispatch_human_click(btn) : false; + if (!clicked) { + const form = document.querySelector('form[action*="validateCaptcha"]'); + if (form) { + try { + form.submit(); + return { ok: true, is_captcha: true, clicked: true, method: 'submit', href }; + } catch (_) { } + } + } + + return { ok: true, is_captcha: true, clicked, method: clicked ? 'dispatch' : 'none', href }; +} + +export function is_amazon_validate_captcha_url(tab_url) { + if (!tab_url || typeof tab_url !== 'string') return false; + return tab_url.includes('amazon.') && tab_url.includes('/errors/validateCaptcha'); +} + +export function sleep_ms(ms) { + const t = Number(ms); + return new Promise((resolve) => setTimeout(resolve, Number.isFinite(t) ? Math.max(0, t) : 0)); +} + +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 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"]'); + 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"]'); + if (btn) { + return { ok: dispatch_human_click(btn) }; + } + const form = input.closest('form'); + if (form) { + form.submit(); + return { ok: true }; + } + return { ok: false, error: 'no_submit' }; +} + +export function injected_amazon_switch_language(params) { + 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 = 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 deadline = Date.now() + 6000; + let link = null; + while (Date.now() < deadline) { + link = document.querySelector(href_sel); + if (link) { + const r = link.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) break; + } + const t0 = performance.now(); + while (performance.now() - t0 < 40) { } + } + if (!link) return { ok: false, error: 'lang_option_timeout', lang: code }; + dispatch_human_click(link); + + const save_deadline = Date.now() + 6000; + let save = null; + while (Date.now() < save_deadline) { + save = + document.querySelector('input[type="submit"][value*="Save"]') || + document.querySelector('input[type="submit"][aria-labelledby*="icp-save-button"]') || + document.querySelector('span.icp-save-button input[type="submit"]'); + if (save) break; + const t1 = performance.now(); + while (performance.now() - t1 < 40) { } + } + if (save) { + dispatch_human_click(save); + } + + return { ok: true, lang: code }; +} + +export function injected_amazon_search_list(params) { + params = params && typeof params === 'object' ? params : {}; + const debug = params.debug === true; + + // validateCaptcha:在 onUpdated(complete) 钩子里也能自动处理 + if ((location.href || '').includes('/errors/validateCaptcha')) { + function dispatch_human_click_local(el) { + if (!el) return false; + try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) { } + try { el.focus && el.focus(); } catch (_) { } + const rect = el.getBoundingClientRect(); + const x = Math.max(1, Math.floor(rect.left + rect.width / 2)); + const y = Math.max(1, Math.floor(rect.top + rect.height / 2)); + const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }; + try { + if (typeof PointerEvent === 'function') { + el.dispatchEvent(new PointerEvent('pointerover', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointerenter', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointermove', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + el.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 1 })); + el.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 })); + } + } catch (_) { } + el.dispatchEvent(new MouseEvent('mousemove', base)); + el.dispatchEvent(new MouseEvent('mouseover', base)); + el.dispatchEvent(new MouseEvent('mousedown', base)); + el.dispatchEvent(new MouseEvent('mouseup', base)); + el.dispatchEvent(new MouseEvent('click', base)); + return true; + } + + const btn = + document.querySelector('form[action="/errors/validateCaptcha"] button[type="submit"].a-button-text') || + document.querySelector('form[action*="validateCaptcha"] input[type="submit"]') || + document.querySelector('form[action*="validateCaptcha"] button[type="submit"]') || + document.querySelector('input[type="submit"][value*="Continue"]') || + document.querySelector('button[type="submit"]'); + const clicked = btn ? dispatch_human_click_local(btn) : false; + if (debug) { + // eslint-disable-next-line no-console + 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() : ''; + + function pick_number(text) { + if (!text) return null; + const s = String(text).replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim(); + const m = s.match(/(\d+(?:\.\d+)?)/); + return m ? Number(m[1]) : null; + } + + function pick_int(text) { + if (!text) return null; + const raw = String(text).replace(/\s+/g, ' ').trim(); + const u = raw.toUpperCase().replace(/,/g, ''); + const km = u.match(/([\d.]+)\s*K\b/); + if (km) return Math.round(parseFloat(km[1]) * 1000); + const mm = u.match(/([\d.]+)\s*M\b/); + if (mm) return Math.round(parseFloat(mm[1]) * 1000000); + const digits = raw.replace(/[^\d]/g, ''); + return digits ? Number(digits) : null; + } + + function abs_url(href) { + try { + return new URL(href, location.origin).toString(); + } catch (_) { + return href; + } + } + + function parse_asin_from_url(url) { + if (!url || typeof url !== 'string') return null; + const m = url.match(/\/dp\/([A-Z0-9]{10})/i) || url.match(/\/gp\/product\/([A-Z0-9]{10})/i); + return m ? m[1].toUpperCase() : null; + } + + function extract_results() { + const items = []; + const nodes = document.querySelectorAll('div.s-main-slot div[data-component-type="s-search-result"]'); + nodes.forEach((el, idx) => { + const asin = (el.getAttribute('data-asin') || '').trim() || null; + const title_el = el.querySelector('h2 span') || el.querySelector('h2'); + const title = title_el ? title_el.textContent.trim() : null; + const a = el.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]'); + const href = a ? a.getAttribute('href') : null; + const item_url = href ? abs_url(href) : null; + const price_el = el.querySelector('span.a-price > span.a-offscreen'); + const price = price_el ? price_el.textContent.trim() : null; + const reviews_block = el.querySelector('div[data-cy="reviews-block"]') || el; + const rating_text = (() => { + const t1 = reviews_block.querySelector('span.a-icon-alt'); + if (t1 && t1.textContent) return t1.textContent.trim(); + const t2 = reviews_block.querySelector('span.a-size-small.a-color-base[aria-hidden="true"]'); + if (t2 && t2.textContent) return t2.textContent.trim(); + return null; + })(); + const rating = (() => { + const n = pick_number(rating_text); + return Number.isFinite(n) ? n : null; + })(); + const review_count_text = (() => { + const a1 = reviews_block.querySelector('a[href*="#customerReviews"]'); + if (a1 && a1.textContent) return a1.textContent.trim(); + const a2 = reviews_block.querySelector( + 'a[aria-label*="rating"], a[aria-label*="ratings"], a[aria-label*="评级"], a[aria-label*="评价"]', + ); + if (a2 && a2.getAttribute('aria-label')) return a2.getAttribute('aria-label').trim(); + const s1 = reviews_block.querySelector('span.a-size-mini.puis-normal-weight-text'); + if (s1 && s1.textContent) return s1.textContent.trim(); + return null; + })(); + const review_count = (() => { + const n = pick_int(review_count_text); + return Number.isFinite(n) ? n : null; + })(); + 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, + }); + }); + return items; + } + + 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.classList && a.classList.contains('s-pagination-disabled')) return null; + const href = a.getAttribute('href'); + if (!href) return null; + return abs_url(href); + } + + 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 (_) { } + } + return out; +} + +export function injected_amazon_product_detail() { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const asin_match = location.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i); + const asin = asin_match ? asin_match[1].toUpperCase() : null; + + const product_info = {}; + function set_info(k, v, max_len) { + 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; + } + + const table_roots = + '#productOverview_feature_div tr, #poExpander table tr, #productDetails_detailBullets_sections1 tr, ' + + '#productDetails_techSpec_section_1 tr, table.prodDetTable tr, #productFactsDesktopExpander tr, ' + + '#technicalSpecifications_feature_div table tr, #productDetails_db_sections tr'; + document.querySelectorAll(table_roots).forEach((tr) => { + const tds = tr.querySelectorAll('td'); + const th = tr.querySelector('th'); + const td = tr.querySelector('td'); + if (tds.length >= 2) set_info(tds[0].innerText, tds[1].innerText); + else if (th && td && th !== td) set_info(th.innerText, td.innerText); + }); + + const detail_extra_lines = []; + document.querySelectorAll('#detailBullets_feature_div li, #rpi-attribute-values_feature_div li').forEach((li) => { + const t = li.innerText.replace(/\u200f|\u200e/g, ' ').replace(/\s+/g, ' ').trim(); + const m = t.match(/^(.{1,80}?)\s*[::]\s*(.+)$/); + if (m) set_info(m[1], m[2], 1200); + else if (t.length > 8 && t.length < 800) detail_extra_lines.push(t); + }); + + const title_el = document.querySelector('#productTitle'); + const title = title_el ? norm(title_el.textContent) : null; + const price_el = + document.querySelector('#corePrice_feature_div .a-price .a-offscreen') || + document.querySelector('#tp_price_block_total_price_ww .a-offscreen') || + document.querySelector('#price .a-offscreen') || + document.querySelector('.reinventPricePriceToPayMargin .a-offscreen') || + document.querySelector('.a-price .a-offscreen'); + const price = price_el ? price_el.textContent.trim() : null; + + const brand_el = document.querySelector('#bylineInfo'); + const brand_line = brand_el ? norm(brand_el.textContent) : null; + const brand_store_url = document.querySelector('#bylineInfo a[href]')?.href || null; + + const rating_stars = document.querySelector('#acrPopover')?.getAttribute('title') || + document.querySelector('#averageCustomerReviews .a-icon-alt')?.textContent?.trim() || null; + const review_count_text = document.querySelector('#acrCustomerReviewText')?.textContent?.trim() || null; + + const ac_badge = norm(document.querySelector('#acBadge_feature_div')?.innerText) || null; + const social_proof = norm(document.querySelector('#socialProofingAsinFaceout_feature_div')?.innerText) || null; + const bestseller_hint = norm(document.querySelector('#zeitgeistBadge_feature_div')?.innerText)?.slice(0, 200) || null; + + const bullets = []; + document.querySelectorAll('#feature-bullets ul li span.a-list-item').forEach((el) => { + const t = norm(el.textContent); + if (t) bullets.push(t); + }); + + let delivery_hint = null; + const del = document.querySelector('#deliveryBlockMessage, #mir-layout-DELIVERY_BLOCK-slot-PRIMARY_DELIVERY_MESSAGE_LARGE'); + 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, + 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; + const nodes = document.querySelectorAll('[data-hook="review"]'); + const items = []; + nodes.forEach((r) => { + if (items.length >= limit) return; + const author_el = r.querySelector('.a-profile-name'); + const author = author_el ? author_el.textContent.trim() : null; + const title_el = r.querySelector('[data-hook="review-title"]'); + const title = title_el ? title_el.innerText.replace(/\s+/g, ' ').trim() : null; + const body_el = r.querySelector('[data-hook="review-body"]'); + const body = body_el ? body_el.innerText.replace(/\s+/g, ' ').trim() : null; + const rating_el = r.querySelector('[data-hook="review-star-rating"]'); + const rating_text = rating_el ? rating_el.textContent.trim() : null; + const date_el = r.querySelector('[data-hook="review-date"]'); + const date = date_el ? date_el.textContent.trim() : null; + const cr = r.querySelector('[id^="customer_review-"]'); + const review_id = r.id || (cr && cr.id ? cr.id.replace('customer_review-', '') : null); + items.push({ index: items.length + 1, review_id, author, rating_text, title, date, body }); + }); + return { stage: 'reviews', limit, total: items.length, items, url: location.href }; +} + +export function normalize_product_url(u) { + let s = u ? String(u).trim() : ''; + if (!s) throw new Error('缺少 product_url'); + if (s.startsWith('//')) s = 'https:' + s; + if (!/^https?:\/\//i.test(s)) s = 'https://' + s; + const url_obj = new URL(s); + if (!url_obj.hostname.includes('amazon.')) throw new Error('product_url 需为亚马逊域名'); + if (!/\/dp\/[A-Z0-9]{10}/i.test(url_obj.pathname) && !/\/gp\/product\/[A-Z0-9]{10}/i.test(url_obj.pathname)) { + throw new Error('product_url 需包含 /dp/ASIN 或 /gp/product/ASIN'); + } + return url_obj.toString(); +} + +export function is_amazon_search_list_url(tab_url) { + if (!tab_url || typeof tab_url !== 'string') return false; + if (!tab_url.includes('amazon.com')) return false; + if (!/\/s(\?|\/)/.test(tab_url)) return false; + 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(); + }); +} diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js index 400eae9..8b74119 100644 --- a/mv2_simple_crx/src/background/index.js +++ b/mv2_simple_crx/src/background/index.js @@ -7,6 +7,7 @@ import { amazon_product_detail_reviews, } from '../actions/amazon.js'; +// action 注册表:供 UI 下拉选择 + server bridge 调用 const actions = { amazon_search_list, amazon_set_language, @@ -40,10 +41,7 @@ function create_action_send_response(sender) { const ui_page_url = chrome.runtime.getURL('ui/index.html'); -function log() { - // eslint-disable-next-line no-console - console.log.apply(console, ['[mv2_simple_crx]'].concat([].slice.call(arguments))); -} + function emit_ui_event(event_name, payload) { chrome.runtime.sendMessage({ diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js index 7ccd7c1..c8ba229 100644 --- a/mv2_simple_crx/src/libs/tabs.js +++ b/mv2_simple_crx/src/libs/tabs.js @@ -8,6 +8,7 @@ function attach_tab_helpers(tab) { tab.remove = function remove(delay_ms) { delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0; setTimeout(() => { + tab.off_update_complete && tab.off_update_complete(); chrome.tabs.remove(tab.id, () => void 0); }, Math.max(0, delay_ms)); }; @@ -16,10 +17,90 @@ function attach_tab_helpers(tab) { return await execute_script(tab.id, fn, args, run_at); }; + /** + * 等待 tab 页面加载完成(status=complete) + * - 作为 tab 方法,避免业务层到处传 tab_id + */ + tab.wait_complete = 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); + }); + }); + }; + + /** + * 你期望的风格: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; + 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 (running) return; + running = true; + 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) }); + } finally { + running = false; + } + }; + + chrome.tabs.onUpdated.addListener(listener); + tab._on_update_complete_listener = listener; + + // 注册时如果已 complete,立即触发一次,保证首屏也能执行注入 + chrome.tabs.get(tab.id, (t0) => { + if (chrome.runtime.lastError) return; + if (t0 && t0.status === 'complete') { + listener(tab.id, { status: 'complete' }, t0); + } + }); + return 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 (_) { } + tab._on_update_complete_listener = null; + }; + tab.close_window = function close_window(delay_ms) { delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0; setTimeout(() => { if (tab.windowId) { + tab.off_update_complete && tab.off_update_complete(); chrome.windows.remove(tab.windowId, () => void 0); } }, Math.max(0, delay_ms)); diff --git a/mv2_simple_crx/src/ui/index.html b/mv2_simple_crx/src/ui/index.html index e9a0da0..0717010 100644 --- a/mv2_simple_crx/src/ui/index.html +++ b/mv2_simple_crx/src/ui/index.html @@ -21,6 +21,7 @@