This commit is contained in:
2026-03-19 14:45:31 +08:00
parent cf3422b1ca
commit 3d5156ac3e
8 changed files with 805 additions and 735 deletions

File diff suppressed because it is too large Load Diff

View 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();
});
}

View File

@@ -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({

View File

@@ -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));

View File

@@ -21,6 +21,7 @@
<div class="form">
<label class="label">方法名action</label>
<!-- action 列表:由 background 注册;这里仅提供快速手动调用入口 -->
<select id="action_name" class="input">
<option value="zhipu_query_position_page">zhipu_query_position_page</option>
<option value="amazon_top_list">amazon_top_list</option>

View File

@@ -4,10 +4,10 @@
*/
export const cron_task_list = [
// 任务流:先跑列表,再依赖列表 URL 跑详情+评论
// 任务流:先跑列表,再依赖列表 URL 跑详情+评论(合并 action
{
name: 'amazon_search_detail_reviews_every_1h',
cron_expression: '* */1 * * *', // 1小时执行一次
cron_expression: '0 */1 * * *', // 1小时执行一次
type: 'flow',
flow_name: 'amazon_search_detail_reviews',
flow_payload: {

View File

@@ -15,6 +15,11 @@ function pick_asin_from_url(url) {
return m && m[1] ? m[1].toUpperCase() : null;
}
/**
* 以“自然日(本地时区)”为口径判断当天是否已抓取过详情。
* - 详情数据写入 `amazon_product`,会更新 `updated_at`
* - 当 `updated_at >= 今日 00:00` 时,后续同日任务将跳过详情提取(仅抓评论)
*/
function get_today_start() {
const d = new Date();
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 skip_detail = asin ? await has_detail_fetched_today(asin) : false;
/**
* 合并 action同一详情页 tab 内一次完成 detail + reviews减少两次打开页面/两次桥接调用)
* - 当 skip_detail=true插件只返回 reviewsdetail 不执行/不返回)
* - 返回结构:{ result: { detail?: {...}, reviews: {...} } }
*/
const res = await execute_action_and_record({
action_name: 'amazon_product_detail_reviews',
action_payload: { product_url: url, limit: reviews_limit, skip_detail },

View File

@@ -5,6 +5,11 @@ import { get_flow_runner } from './flows/flow_registry.js';
const cron_jobs = [];
const running_task_name_set = new Set();
/**
* 启动参数开关(用于本地调试/冷启动后立即跑一次 cron
* - 通过 VSCode/Cursor 的 launch.json 传入:--run_cron_now
* - 目的:避免等待 cron 表达式下一次触发(尤其是小时级任务)
*/
function has_argv_flag(flag_name) {
const name = String(flag_name || '').trim();
if (!name) return false;
@@ -20,7 +25,7 @@ async function run_cron_task(task) {
throw new Error('cron_task 缺少 type');
}
// 当前项目 cron 只允许跑 flow任务入口集中便于统一治理
if (task.type === 'flow') {
const run_flow = get_flow_runner(task.flow_name);
await run_flow(task.flow_payload || {});
@@ -30,6 +35,11 @@ async function run_cron_task(task) {
throw new Error(`cron_task type 不支持: ${task.type}`);
}
/**
* 统一的“防重复运行 + 执行 + 错误兜底”入口
* - 防止同一任务执行时间过长时,被下一次 cron 触发叠加执行
* - run_now 与定时触发复用同一套 guard保证行为一致
*/
async function run_cron_task_with_guard(task_name, task) {
if (running_task_name_set.has(task_name)) {
// eslint-disable-next-line no-console
@@ -52,19 +62,19 @@ export async function start_all_cron_tasks() {
for (const task of cron_task_list) {
const task_name = task && task.name ? String(task.name) : 'cron_task';
// 先注册 cron无论是否 run_now都需要后续按表达式持续执行
const job = cron.schedule(task.cron_expression, async () => {
await run_cron_task_with_guard(task_name, task);
});
console.log('job', { task_name, });
cron_jobs.push(job);
if (run_now) {
// 启动时额外立刻跑一次(仍走 guard避免与 cron 触发撞车)
// eslint-disable-next-line no-console
console.log('[cron] run_now', { task_name });
await run_cron_task_with_guard(task_name, task);
}
else {
const job = cron.schedule(task.cron_expression, async () => {
await run_cron_task_with_guard(task_name, task);
});
console.log('job', { task_name, });
cron_jobs.push(job);
}
}
}