1
This commit is contained in:
@@ -1,513 +1,35 @@
|
|||||||
// Amazon:搜索列表 + 商品详情 + 评论(注入函数与 action 同文件,便于维护)
|
// Amazon:action(编排逻辑放这里),注入函数放 amazon_tool.js
|
||||||
|
|
||||||
import { create_tab_task } from '../libs/tabs.js';
|
import { create_tab_task } from '../libs/tabs.js';
|
||||||
|
import {
|
||||||
// ---------- 页面注入(仅依赖页面 DOM,勿引用本文件其它符号) ----------
|
injected_amazon_homepage_search,
|
||||||
|
injected_amazon_product_detail,
|
||||||
export function injected_amazon_homepage_search(params) {
|
injected_amazon_product_reviews,
|
||||||
const keyword = params && params.keyword ? String(params.keyword).trim() : '';
|
injected_amazon_search_list,
|
||||||
if (!keyword) return { ok: false, error: 'empty_keyword' };
|
injected_amazon_switch_language,
|
||||||
const input =
|
injected_amazon_validate_captcha_continue,
|
||||||
document.querySelector('#twotabsearchtextbox') ||
|
normalize_product_url,
|
||||||
document.querySelector('input#nav-search-keywords') ||
|
try_solve_amazon_validate_captcha,
|
||||||
document.querySelector('input[name="field-keywords"]');
|
wait_tab_complete,
|
||||||
if (!input) return { ok: false, error: 'no_search_input' };
|
wait_until_search_list_url,
|
||||||
input.focus();
|
} from './amazon_tool.js';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 后台:搜索列表 ----------
|
|
||||||
|
|
||||||
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
|
const AMAZON_ZH_HOME_URL = 'https://www.amazon.com/-/zh/ref=nav_logo';
|
||||||
|
const AMAZON_HOME_FOR_LANG =
|
||||||
/** 英文搜索列表 URL 模板(与 language=en_US 一致,仅替换 k) */
|
'https://www.amazon.com/customer-preferences/edit?ie=UTF8&preferencesReturnUrl=%2F&ref_=topnav_lang_ais&language=zh_CN¤cy=HKD';
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function amazon_search_list(data, sendResponse) {
|
export function amazon_search_list(data, sendResponse) {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const category_keyword = data && data.category_keyword ? String(data.category_keyword).trim() : '';
|
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 sort_by = data && data.sort_by ? String(data.sort_by).trim() : '';
|
||||||
|
const keep_tab_open = data && data.keep_tab_open === true;
|
||||||
const limit = (() => {
|
const limit = (() => {
|
||||||
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100;
|
const n = data && Object.prototype.hasOwnProperty.call(data, 'limit') ? Number(data.limit) : 100;
|
||||||
if (!Number.isFinite(n)) return 100;
|
if (!Number.isFinite(n)) return 100;
|
||||||
return Math.max(1, Math.min(200, Math.floor(n)));
|
return Math.max(1, Math.min(200, Math.floor(n)));
|
||||||
})();
|
})();
|
||||||
const keyword = category_keyword || 'picnic bag';
|
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 = {
|
const sort_map = {
|
||||||
featured: 'relevanceblender',
|
featured: 'relevanceblender',
|
||||||
review: 'review-rank',
|
review: 'review-rank',
|
||||||
@@ -517,52 +39,72 @@ export function amazon_search_list(data, sendResponse) {
|
|||||||
bestseller: 'exact-aware-popularity-rank',
|
bestseller: 'exact-aware-popularity-rank',
|
||||||
};
|
};
|
||||||
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
|
const sort_s = Object.prototype.hasOwnProperty.call(sort_map, sort_by) ? sort_map[sort_by] : '';
|
||||||
|
|
||||||
const send_action = (action, payload) => {
|
const send_action = (action, payload) => {
|
||||||
if (typeof sendResponse === 'function') {
|
if (typeof sendResponse === 'function') {
|
||||||
sendResponse({ action, data: payload });
|
sendResponse({ action, data: payload });
|
||||||
sendResponse.log && sendResponse.log(payload);
|
sendResponse.log && sendResponse.log(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
|
const tab_task = create_tab_task(AMAZON_ZH_HOME_URL)
|
||||||
.set_latest(false)
|
.set_latest(false)
|
||||||
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
|
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
|
||||||
.set_target('__amazon_search_list');
|
.set_target('__amazon_search_list');
|
||||||
|
|
||||||
let url = AMAZON_ZH_HOME_URL;
|
let url = AMAZON_ZH_HOME_URL;
|
||||||
try {
|
try {
|
||||||
const tab = await tab_task.open_async();
|
const tab = await tab_task.open_async();
|
||||||
await wait_tab_complete(tab.id);
|
let running = false;
|
||||||
const home_ret = await tab.execute_script(injected_amazon_homepage_search, [{ keyword }], 'document_idle');
|
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;
|
const home_ok = Array.isArray(home_ret) ? home_ret[0] : home_ret;
|
||||||
if (!home_ok || !home_ok.ok) {
|
if (!home_ok || !home_ok.ok) {
|
||||||
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
|
throw new Error((home_ok && home_ok.error) || '首页搜索提交失败');
|
||||||
}
|
}
|
||||||
url = await wait_until_search_list_url(tab.id, 45000);
|
|
||||||
await wait_tab_complete(tab.id);
|
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) {
|
if (sort_s) {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
u.searchParams.set('s', sort_s);
|
u.searchParams.set('s', sort_s);
|
||||||
url = u.toString();
|
url = u.toString();
|
||||||
await new Promise((resolve_nav, reject_nav) => {
|
await new Promise((resolve_nav, reject_nav) => {
|
||||||
chrome.tabs.update(tab.id, { url, active: true }, () => {
|
chrome.tabs.update(t.id, { url, active: true }, () => {
|
||||||
if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message));
|
if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message));
|
||||||
resolve_nav(true);
|
resolve_nav(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await wait_tab_complete(tab.id);
|
await t.wait_complete();
|
||||||
|
await try_solve_amazon_validate_captcha(t, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unique_map = new Map();
|
const unique_map = new Map();
|
||||||
let next_url = url;
|
let next_url = url;
|
||||||
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
|
for (let page = 1; page <= 10 && unique_map.size < limit; page += 1) {
|
||||||
if (page > 1) {
|
if (page > 1) {
|
||||||
await new Promise((resolve_nav, reject_nav) => {
|
await new Promise((resolve_nav, reject_nav) => {
|
||||||
chrome.tabs.update(tab.id, { url: next_url, active: true }, () => {
|
chrome.tabs.update(t.id, { url: next_url, active: true }, () => {
|
||||||
if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message));
|
if (chrome.runtime.lastError) return reject_nav(new Error(chrome.runtime.lastError.message));
|
||||||
resolve_nav(true);
|
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 tab.execute_script(
|
const injected_result_list = await t.execute_script(
|
||||||
injected_amazon_search_list,
|
injected_amazon_search_list,
|
||||||
[{ url: next_url, category_keyword, sort_by }],
|
[{ url: next_url, category_keyword, sort_by }],
|
||||||
'document_idle',
|
'document_idle',
|
||||||
@@ -578,21 +120,37 @@ export function amazon_search_list(data, sendResponse) {
|
|||||||
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
|
next_url = injected_result && injected_result.next_url ? String(injected_result.next_url) : null;
|
||||||
if (!next_url) break;
|
if (!next_url) break;
|
||||||
}
|
}
|
||||||
const list_result = {
|
|
||||||
stage: 'list',
|
const list_result = { stage: 'list', limit, total: unique_map.size, items: Array.from(unique_map.values()).slice(0, limit) };
|
||||||
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 } };
|
||||||
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);
|
send_action('amazon_search_list', result);
|
||||||
resolve({ tab_id: tab.id, url, category_keyword, sort_by: sort_by || 'featured', limit, result: list_result });
|
if (!resolved) {
|
||||||
tab.remove(0);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send_action('amazon_search_list', {
|
send_action('amazon_search_list', {
|
||||||
code: 30,
|
code: 30,
|
||||||
@@ -609,18 +167,13 @@ export function amazon_search_list(data, sendResponse) {
|
|||||||
amazon_search_list.desc = 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取';
|
amazon_search_list.desc = 'Amazon 搜索列表:先打开中文首页,搜索框输入类目并搜索,再分页抓取';
|
||||||
amazon_search_list.params = {
|
amazon_search_list.params = {
|
||||||
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
|
category_keyword: { type: 'string', desc: '类目关键词(在首页搜索框输入后点搜索,进入列表再抓)', default: '野餐包' },
|
||||||
sort_by: {
|
sort_by: { type: 'string', desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller', default: 'featured' },
|
||||||
type: 'string',
|
|
||||||
desc: '排序方式:featured / price_asc / price_desc / review / newest / bestseller',
|
|
||||||
default: 'featured',
|
|
||||||
},
|
|
||||||
limit: { type: 'number', desc: '抓取数量上限(默认 100,最大 200)', default: 100 },
|
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) {
|
export function amazon_set_language(data, sendResponse) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
const mapping = {
|
const mapping = {
|
||||||
EN: 'en_US',
|
EN: 'en_US',
|
||||||
ES: 'es_US',
|
ES: 'es_US',
|
||||||
@@ -634,48 +187,40 @@ export function amazon_set_language(data, sendResponse) {
|
|||||||
};
|
};
|
||||||
const raw_lang = data && data.lang != null ? String(data.lang).trim().toUpperCase() : 'ZH_CN';
|
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 code = Object.prototype.hasOwnProperty.call(mapping, raw_lang) ? raw_lang : 'ZH_CN';
|
||||||
|
|
||||||
const send_action = (action, payload) => {
|
const send_action = (action, payload) => {
|
||||||
if (typeof sendResponse === 'function') {
|
if (typeof sendResponse === 'function') {
|
||||||
sendResponse({ action, data: payload });
|
sendResponse({ action, data: payload });
|
||||||
sendResponse.log && sendResponse.log(payload);
|
sendResponse.log && sendResponse.log(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
|
const tab_task = create_tab_task(AMAZON_HOME_FOR_LANG)
|
||||||
.set_latest(false)
|
.set_latest(false)
|
||||||
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
.set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||||
try {
|
try {
|
||||||
const tab = await tab_task.open_async();
|
const tab = await tab_task.open_async();
|
||||||
await wait_tab_complete(tab.id);
|
tab.on_update_complete(async (t) => {
|
||||||
const raw = await tab.execute_script(injected_amazon_switch_language, [{ lang: code }], 'document_idle');
|
// 首次 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;
|
const inj = Array.isArray(raw) ? raw[0] : raw;
|
||||||
if (!inj || !inj.ok) {
|
if (!inj || !inj.ok) {
|
||||||
throw new Error((inj && inj.error) || 'switch_language_failed');
|
throw new Error((inj && inj.error) || 'switch_language_failed');
|
||||||
}
|
}
|
||||||
await wait_tab_complete(tab.id);
|
|
||||||
const final_url = await new Promise((res, rej) => {
|
const final_url = await new Promise((res, rej) => {
|
||||||
chrome.tabs.get(tab.id, (t) => {
|
chrome.tabs.get(t.id, (tt) => {
|
||||||
if (chrome.runtime.lastError) return rej(new Error(chrome.runtime.lastError.message));
|
if (chrome.runtime.lastError) return rej(new Error(chrome.runtime.lastError.message));
|
||||||
res(t && t.url ? t.url : '');
|
res(tt && tt.url ? tt.url : '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const result = {
|
const result = { code: 0, status: true, message: 'ok', data: { tab_id: t.id, lang: inj.lang, url: final_url } };
|
||||||
code: 0,
|
|
||||||
status: true,
|
|
||||||
message: 'ok',
|
|
||||||
data: { tab_id: tab.id, lang: inj.lang, url: final_url },
|
|
||||||
};
|
|
||||||
send_action('amazon_set_language', result);
|
send_action('amazon_set_language', result);
|
||||||
resolve({ tab_id: tab.id, lang: inj.lang, url: final_url });
|
resolve({ tab_id: t.id, lang: inj.lang, url: final_url });
|
||||||
tab.remove(0);
|
t.remove(0);
|
||||||
|
}, { once: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send_action('amazon_set_language', {
|
send_action('amazon_set_language', { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: AMAZON_HOME_FOR_LANG });
|
||||||
code: 30,
|
|
||||||
status: false,
|
|
||||||
message: (err && err.message) || String(err),
|
|
||||||
data: null,
|
|
||||||
documentURI: AMAZON_HOME_FOR_LANG,
|
|
||||||
});
|
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -684,26 +229,39 @@ export function amazon_set_language(data, sendResponse) {
|
|||||||
amazon_set_language.desc =
|
amazon_set_language.desc =
|
||||||
'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言';
|
'Amazon 顶栏语言:打开美站首页,悬停语言区后点击列表项(#switch-lang),切换购物界面语言';
|
||||||
amazon_set_language.params = {
|
amazon_set_language.params = {
|
||||||
lang: {
|
lang: { type: 'string', desc: 'EN / ES / AR / DE / HE / KO / PT / ZH_CN(默认) / ZH_TW', default: 'ZH_CN' },
|
||||||
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) {
|
amazon_product_detail.desc =
|
||||||
let s = u ? String(u).trim() : '';
|
'Amazon 商品详情(标题、价格、品牌、sku{color[],size[]}、要点、配送摘要等)';
|
||||||
if (!s) throw new Error('缺少 product_url');
|
amazon_product_detail.params = {
|
||||||
if (s.startsWith('//')) s = 'https:' + s;
|
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||||
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 需为亚马逊域名');
|
export function amazon_product_reviews(data, sendResponse) {
|
||||||
if (!/\/dp\/[A-Z0-9]{10}/i.test(url_obj.pathname) && !/\/gp\/product\/[A-Z0-9]{10}/i.test(url_obj.pathname)) {
|
const limit = data && data.limit != null ? Number(data.limit) : 50;
|
||||||
throw new Error('product_url 需包含 /dp/ASIN 或 /gp/product/ASIN');
|
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) {
|
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 });
|
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||||
try {
|
try {
|
||||||
const tab = await tab_task.open_async();
|
const tab = await tab_task.open_async();
|
||||||
await wait_tab_complete(tab.id);
|
tab.on_update_complete(async (t) => {
|
||||||
const raw_list = await tab.execute_script(injected_fn, inject_args || [], 'document_idle');
|
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;
|
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||||
send_action(action_name, {
|
send_action(action_name, { code: 0, status: true, message: 'ok', data: { tab_id: t.id, product_url: url, result } });
|
||||||
code: 0,
|
resolve({ tab_id: t.id, product_url: url, result });
|
||||||
status: true,
|
t.remove(0);
|
||||||
message: 'ok',
|
}, { once: true });
|
||||||
data: { tab_id: tab.id, product_url: url, result },
|
|
||||||
});
|
|
||||||
resolve({ tab_id: tab.id, product_url: url, result });
|
|
||||||
tab.remove(0);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send_action(action_name, {
|
send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url });
|
||||||
code: 30,
|
|
||||||
status: false,
|
|
||||||
message: (err && err.message) || String(err),
|
|
||||||
data: null,
|
|
||||||
documentURI: url,
|
|
||||||
});
|
|
||||||
reject(err);
|
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 });
|
const tab_task = create_tab_task(url).set_bounds({ top: 20, left: 20, width: 1280, height: 900 });
|
||||||
try {
|
try {
|
||||||
const tab = await tab_task.open_async();
|
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 = {};
|
const results = {};
|
||||||
for (const step of steps || []) {
|
for (const step of steps || []) {
|
||||||
if (!step || !step.name || typeof step.injected_fn !== 'function') continue;
|
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 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;
|
const result = Array.isArray(raw_list) ? raw_list[0] : raw_list;
|
||||||
results[step.name] = result;
|
results[step.name] = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
send_action(action_name, {
|
send_action(action_name, { code: 0, status: true, message: 'ok', data: { tab_id: t.id, product_url: url, result: results } });
|
||||||
code: 0,
|
resolve({ tab_id: t.id, product_url: url, result: results });
|
||||||
status: true,
|
t.remove(0);
|
||||||
message: 'ok',
|
}, { once: true });
|
||||||
data: { tab_id: tab.id, product_url: url, result: results },
|
|
||||||
});
|
|
||||||
resolve({ tab_id: tab.id, product_url: url, result: results });
|
|
||||||
tab.remove(0);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send_action(action_name, {
|
send_action(action_name, { code: 30, status: false, message: (err && err.message) || String(err), data: null, documentURI: url });
|
||||||
code: 30,
|
|
||||||
status: false,
|
|
||||||
message: (err && err.message) || String(err),
|
|
||||||
data: null,
|
|
||||||
documentURI: url,
|
|
||||||
});
|
|
||||||
reject(err);
|
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.desc = 'Amazon 商品详情 + 评论(同一详情页,支持 skip_detail=true)';
|
||||||
amazon_product_detail_reviews.params = {
|
amazon_product_detail_reviews.params = {
|
||||||
product_url: {
|
product_url: { type: 'string', desc: '商品详情页完整 URL(含 /dp/ASIN)', default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC' },
|
||||||
type: 'string',
|
|
||||||
desc: '商品详情页完整 URL(含 /dp/ASIN)',
|
|
||||||
default: 'https://www.amazon.com/-/zh/dp/B0B56CHMSC',
|
|
||||||
},
|
|
||||||
limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 },
|
limit: { type: 'number', desc: '最多评论条数(默认 50,上限 100)', default: 50 },
|
||||||
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
|
skip_detail: { type: 'boolean', desc: '当日已拉过详情则跳过详情提取', default: false },
|
||||||
};
|
};
|
||||||
|
|||||||
479
mv2_simple_crx/src/actions/amazon_tool.js
Normal file
479
mv2_simple_crx/src/actions/amazon_tool.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
amazon_product_detail_reviews,
|
amazon_product_detail_reviews,
|
||||||
} from '../actions/amazon.js';
|
} from '../actions/amazon.js';
|
||||||
|
|
||||||
|
// action 注册表:供 UI 下拉选择 + server bridge 调用
|
||||||
const actions = {
|
const actions = {
|
||||||
amazon_search_list,
|
amazon_search_list,
|
||||||
amazon_set_language,
|
amazon_set_language,
|
||||||
@@ -40,10 +41,7 @@ function create_action_send_response(sender) {
|
|||||||
|
|
||||||
const ui_page_url = chrome.runtime.getURL('ui/index.html');
|
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) {
|
function emit_ui_event(event_name, payload) {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function attach_tab_helpers(tab) {
|
|||||||
tab.remove = function remove(delay_ms) {
|
tab.remove = function remove(delay_ms) {
|
||||||
delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0;
|
delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
tab.off_update_complete && tab.off_update_complete();
|
||||||
chrome.tabs.remove(tab.id, () => void 0);
|
chrome.tabs.remove(tab.id, () => void 0);
|
||||||
}, Math.max(0, delay_ms));
|
}, Math.max(0, delay_ms));
|
||||||
};
|
};
|
||||||
@@ -16,10 +17,90 @@ function attach_tab_helpers(tab) {
|
|||||||
return await execute_script(tab.id, fn, args, run_at);
|
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) {
|
tab.close_window = function close_window(delay_ms) {
|
||||||
delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0;
|
delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (tab.windowId) {
|
if (tab.windowId) {
|
||||||
|
tab.off_update_complete && tab.off_update_complete();
|
||||||
chrome.windows.remove(tab.windowId, () => void 0);
|
chrome.windows.remove(tab.windowId, () => void 0);
|
||||||
}
|
}
|
||||||
}, Math.max(0, delay_ms));
|
}, Math.max(0, delay_ms));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<div class="form">
|
<div class="form">
|
||||||
<label class="label">方法名(action)</label>
|
<label class="label">方法名(action)</label>
|
||||||
|
<!-- action 列表:由 background 注册;这里仅提供快速手动调用入口 -->
|
||||||
<select id="action_name" class="input">
|
<select id="action_name" class="input">
|
||||||
<option value="zhipu_query_position_page">zhipu_query_position_page</option>
|
<option value="zhipu_query_position_page">zhipu_query_position_page</option>
|
||||||
<option value="amazon_top_list">amazon_top_list</option>
|
<option value="amazon_top_list">amazon_top_list</option>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const cron_task_list = [
|
export const cron_task_list = [
|
||||||
// 任务流:先跑列表,再依赖列表 URL 跑详情+评论
|
// 任务流:先跑列表,再依赖列表 URL 跑“详情+评论(合并 action)”
|
||||||
{
|
{
|
||||||
name: 'amazon_search_detail_reviews_every_1h',
|
name: 'amazon_search_detail_reviews_every_1h',
|
||||||
cron_expression: '* */1 * * *', // 1小时执行一次
|
cron_expression: '0 */1 * * *', // 1小时执行一次
|
||||||
type: 'flow',
|
type: 'flow',
|
||||||
flow_name: 'amazon_search_detail_reviews',
|
flow_name: 'amazon_search_detail_reviews',
|
||||||
flow_payload: {
|
flow_payload: {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function pick_asin_from_url(url) {
|
|||||||
return m && m[1] ? m[1].toUpperCase() : null;
|
return m && m[1] ? m[1].toUpperCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以“自然日(本地时区)”为口径判断当天是否已抓取过详情。
|
||||||
|
* - 详情数据写入 `amazon_product`,会更新 `updated_at`
|
||||||
|
* - 当 `updated_at >= 今日 00:00` 时,后续同日任务将跳过详情提取(仅抓评论)
|
||||||
|
*/
|
||||||
function get_today_start() {
|
function get_today_start() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
@@ -214,6 +219,11 @@ export async function run_amazon_search_detail_reviews_flow(flow_payload) {
|
|||||||
const asin = pick_asin_from_url(url);
|
const asin = pick_asin_from_url(url);
|
||||||
const skip_detail = asin ? await has_detail_fetched_today(asin) : false;
|
const skip_detail = asin ? await has_detail_fetched_today(asin) : false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并 action:同一详情页 tab 内一次完成 detail + reviews(减少两次打开页面/两次桥接调用)
|
||||||
|
* - 当 skip_detail=true:插件只返回 reviews(detail 不执行/不返回)
|
||||||
|
* - 返回结构:{ result: { detail?: {...}, reviews: {...} } }
|
||||||
|
*/
|
||||||
const res = await execute_action_and_record({
|
const res = await execute_action_and_record({
|
||||||
action_name: 'amazon_product_detail_reviews',
|
action_name: 'amazon_product_detail_reviews',
|
||||||
action_payload: { product_url: url, limit: reviews_limit, skip_detail },
|
action_payload: { product_url: url, limit: reviews_limit, skip_detail },
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { get_flow_runner } from './flows/flow_registry.js';
|
|||||||
const cron_jobs = [];
|
const cron_jobs = [];
|
||||||
const running_task_name_set = new Set();
|
const running_task_name_set = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动参数开关(用于本地调试/冷启动后立即跑一次 cron)
|
||||||
|
* - 通过 VSCode/Cursor 的 launch.json 传入:--run_cron_now
|
||||||
|
* - 目的:避免等待 cron 表达式下一次触发(尤其是小时级任务)
|
||||||
|
*/
|
||||||
function has_argv_flag(flag_name) {
|
function has_argv_flag(flag_name) {
|
||||||
const name = String(flag_name || '').trim();
|
const name = String(flag_name || '').trim();
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
@@ -20,7 +25,7 @@ async function run_cron_task(task) {
|
|||||||
throw new Error('cron_task 缺少 type');
|
throw new Error('cron_task 缺少 type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前项目 cron 只允许跑 flow:任务入口集中,便于统一治理
|
||||||
if (task.type === 'flow') {
|
if (task.type === 'flow') {
|
||||||
const run_flow = get_flow_runner(task.flow_name);
|
const run_flow = get_flow_runner(task.flow_name);
|
||||||
await run_flow(task.flow_payload || {});
|
await run_flow(task.flow_payload || {});
|
||||||
@@ -30,6 +35,11 @@ async function run_cron_task(task) {
|
|||||||
throw new Error(`cron_task type 不支持: ${task.type}`);
|
throw new Error(`cron_task type 不支持: ${task.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的“防重复运行 + 执行 + 错误兜底”入口
|
||||||
|
* - 防止同一任务执行时间过长时,被下一次 cron 触发叠加执行
|
||||||
|
* - run_now 与定时触发复用同一套 guard,保证行为一致
|
||||||
|
*/
|
||||||
async function run_cron_task_with_guard(task_name, task) {
|
async function run_cron_task_with_guard(task_name, task) {
|
||||||
if (running_task_name_set.has(task_name)) {
|
if (running_task_name_set.has(task_name)) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -52,18 +62,18 @@ export async function start_all_cron_tasks() {
|
|||||||
for (const task of cron_task_list) {
|
for (const task of cron_task_list) {
|
||||||
const task_name = task && task.name ? String(task.name) : 'cron_task';
|
const task_name = task && task.name ? String(task.name) : 'cron_task';
|
||||||
|
|
||||||
if (run_now) {
|
// 先注册 cron(无论是否 run_now,都需要后续按表达式持续执行)
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[cron] run_now', { task_name });
|
|
||||||
await run_cron_task_with_guard(task_name, task);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const job = cron.schedule(task.cron_expression, async () => {
|
const job = cron.schedule(task.cron_expression, async () => {
|
||||||
await run_cron_task_with_guard(task_name, task);
|
await run_cron_task_with_guard(task_name, task);
|
||||||
});
|
});
|
||||||
console.log('job', { task_name, });
|
console.log('job', { task_name, });
|
||||||
cron_jobs.push(job);
|
cron_jobs.push(job);
|
||||||
|
|
||||||
|
if (run_now) {
|
||||||
|
// 启动时额外立刻跑一次(仍走 guard,避免与 cron 触发撞车)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[cron] run_now', { task_name });
|
||||||
|
await run_cron_task_with_guard(task_name, task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user