This commit is contained in:
张成
2026-03-17 17:55:06 +08:00
parent e98b08c7bf
commit 99748a59bf
8 changed files with 201 additions and 220 deletions

View File

@@ -1,10 +1,10 @@
// amazon_top_listAmazon TOP 榜单抓取Best Sellers / New Releases / Movers & Shakers
import { openTab } from '../libs/tabs.js';
import { execute_script } from '../libs/inject.js';
import { create_tab_task } from '../libs/tabs.js';
import { injected_amazon_search_list } from '../injected/amazon_search_list.js';
export function amazon_search_list(data, sendResponse) {
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
const category_keyword = (data && data.category_keyword) ? String(data.category_keyword).trim() : '';
const keyword = category_keyword || '野餐包';
@@ -24,159 +24,40 @@ export function amazon_search_list(data, sendResponse) {
let times = 0;
openTab({
url,
latest: false,
top: 20,
left: 20,
width: 1440,
height: 900,
target: '__amazon_search_list',
const send_action = (action, payload) => {
if (typeof sendResponse === 'function') {
sendResponse({ action, data: payload });
sendResponse.log && sendResponse.log(payload);
}
};
tabError(tab, details) {
const tab_task = create_tab_task(url)
.set_latest(false)
.set_bounds({ top: 20, left: 20, width: 1440, height: 900 })
.set_target('__amazon_search_list');
try {
const tab = await tab_task.open_async();
await tab.execute_script(injected_amazon_search_list, [{ url, category_keyword }], 'document_idle');
const result = { code: 0, status: true, message: 'ok', data: { tab_id: tab.id, url, category_keyword } };
send_action('amazon_search_list', result);
resolve({ tab_id: tab.id, url, category_keyword });
// 可选:自动关 tab默认不关方便调试
// tab && tab.remove && tab.remove(3500);
} catch (err) {
const result = {
code: 30,
status: false,
message: (details && (details.message || details.statusLine || details.error)) || 'tab error',
message: (err && err.message) || String(err),
data: null,
documentURI: details && details.url,
documentURI: url,
};
sendResponse && sendResponse({ action: 'amazon_search_list', data: result });
sendResponse && sendResponse.log && sendResponse.log(result);
if (tab && tab.remove) {
tab.remove(1500);
}
reject(new Error(result.message));
},
tabUpdated(tab, details) {
if (times > 0) return;
times += 1;
const tab_id = tab && tab.id;
if (!tab_id) {
return reject(new Error('tab.id 为空'));
}
execute_script(
tab_id,
{
fn: (params) => {
const action = 'amazon_search_list';
const start_url = params && params.url ? String(params.url) : location.href;
const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : '';
function send(action_name, data) {
try {
chrome.runtime.sendMessage({ type: 'push', action: action_name, data });
} catch (e) {
// ignore
}
}
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;
// 搜索页有的标题在 h2 里,不一定包在 <a> 里;链接一般在 a[href*="/dp/"]
const a = el.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]');
const href = a ? a.getAttribute('href') : null;
const 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 rating_el = el.querySelector('span.a-icon-alt');
const rating_text = rating_el ? rating_el.textContent.trim() : null;
// 评论数在不同语言/布局下 class 不稳定,这里做弱依赖
const review_count_el =
el.querySelector('span[aria-label$="ratings"]') ||
el.querySelector('span[aria-label$="rating"]') ||
el.querySelector('span[aria-label$="评价"]') ||
el.querySelector('span[aria-label$="评分"]');
const review_count_text = review_count_el ? review_count_el.textContent.trim() : null;
items.push({
index: idx + 1,
asin: asin || parse_asin_from_url(url),
title,
url,
price,
rating_text,
review_count_text,
});
});
return items;
}
const items = extract_results();
send(action, {
stage: 'start',
start_url,
href: location.href,
category_keyword,
total: items.length,
});
// 不逐条推送,直接一次性推完整列表
send(action, {
stage: 'list',
category_keyword,
total: items.length,
items,
});
send(action, { stage: 'end', category_keyword, total: items.length });
},
args: [{ url, category_keyword }],
},
'document_idle',
)
.then(() => {
const result = {
code: 0,
status: true,
message: 'ok',
data: { tab_id, url, category_keyword },
};
sendResponse && sendResponse({ action: 'amazon_search_list', data: result });
sendResponse && sendResponse.log && sendResponse.log(result);
resolve({ tab_id, url, category_keyword });
})
.catch((err) => {
if (tab && tab.remove) {
tab.remove(500);
}
send_action('amazon_search_list', result);
reject(err);
});
},
});
}
});
}

View File

@@ -101,17 +101,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const action_send_response = create_action_send_response(sender);
Promise.resolve()
.then(() => fn(message.data || {}, action_send_response))
.then((res) => {
(async () => {
try {
const res = await fn(message.data || {}, action_send_response);
emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender });
sendResponse({ ok: true, data: res, request_id });
})
.catch((err) => {
} catch (err) {
const error = (err && err.message) || String(err);
emit_ui_event('response', { type: 'response', request_id, ok: false, error, sender });
sendResponse({ ok: false, error, request_id });
});
}
})();
return true;
});

View File

@@ -112,8 +112,9 @@
if (options.body !== undefined) detail.data = options.body;
}
return target(...args)
.then(async (response) => {
return (async () => {
try {
const response = await target(...args);
detail.ok = true;
detail.message = 'ok';
const cloneResponse = response.clone();
@@ -122,7 +123,6 @@
detail.redirected = cloneResponse.redirected;
detail.url = cloneResponse.url;
detail.text = await cloneResponse.text();
detail.response = response.clone();
detail.responseContentType = response.headers.get('Content-Type')?.toLowerCase();
if (
detail.responseContentType &&
@@ -131,14 +131,12 @@
detail.json = await response.clone().json();
}
return response;
})
.catch((error) => {
} catch (error) {
detail.ok = false;
detail.error = error;
detail.message = error.message;
throw error;
})
.finally(() => {
} finally {
const payload = {
TAG: detail.TAG,
method: detail.method,
@@ -154,7 +152,8 @@
window.dispatchEvent(new CustomEvent('__REQUEST_DONE', { detail: payload }));
detail = null;
});
}
})();
},
});
}

View File

@@ -0,0 +1,75 @@
// 注入到页面的 Amazon 搜索列表解析逻辑
export function injected_amazon_search_list(params) {
const action = 'amazon_search_list';
const start_url = params && params.url ? String(params.url) : location.href;
const category_keyword = params && params.category_keyword ? String(params.category_keyword).trim() : '';
function send(action_name, data) {
try {
chrome.runtime.sendMessage({ type: 'push', action: action_name, data });
} catch (e) {
// ignore
}
}
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 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 rating_el = el.querySelector('span.a-icon-alt');
const rating_text = rating_el ? rating_el.textContent.trim() : null;
const review_count_el =
el.querySelector('span[aria-label$="ratings"]') ||
el.querySelector('span[aria-label$="rating"]') ||
el.querySelector('span[aria-label$="评价"]') ||
el.querySelector('span[aria-label$="评分"]');
const review_count_text = review_count_el ? review_count_el.textContent.trim() : null;
items.push({
index: idx + 1,
asin: asin || parse_asin_from_url(url),
title,
url,
price,
rating_text,
review_count_text,
});
});
return items;
}
const items = extract_results();
send(action, { stage: 'start', start_url, href: location.href, category_keyword, total: items.length });
send(action, { stage: 'list', category_keyword, total: items.length, items });
send(action, { stage: 'end', category_keyword, total: items.length });
}

View File

@@ -1,23 +1,19 @@
// executeScriptMV2 使用 chrome.tabs.executeScript
function normalize_code(code) {
// 支持:直接传函数
if (typeof code === 'function') {
return `(${code.toString()})();`;
function build_code(fn, args) {
if (typeof fn === 'function') {
if (Array.isArray(args) && args.length) {
return `(${fn.toString()}).apply(null, ${JSON.stringify(args)});`;
}
// 支持:传 { fn, args },这样 action 文件里不需要拼字符串也能传参
if (code && typeof code === 'object' && typeof code.fn === 'function') {
const args = Array.isArray(code.args) ? code.args : [];
return `(${code.fn.toString()}).apply(null, ${JSON.stringify(args)});`;
return `(${fn.toString()})();`;
}
return code;
return fn;
}
export function execute_script(tab_id, code, run_at) {
// execute_script(tabId, fn, args?, runAt?)
export function execute_script(tab_id, fn, args, run_at) {
run_at = run_at || 'document_idle';
code = normalize_code(code);
const code = build_code(fn, args);
return new Promise((resolve, reject) => {
chrome.tabs.executeScript(

View File

@@ -1,5 +1,7 @@
// openTabMV2 版本(极简 + 回调风格)
import { execute_script } from './inject.js';
function attach_tab_helpers(tab) {
if (!tab) return tab;
@@ -10,6 +12,10 @@ function attach_tab_helpers(tab) {
}, Math.max(0, delay_ms));
};
tab.execute_script = async function execute_script_on_tab(fn, args, run_at) {
return await execute_script(tab.id, fn, args, run_at);
};
return tab;
}
@@ -55,24 +61,46 @@ export function close_tab(tab_id, delay_ms) {
}, Math.max(0, delay_ms));
}
// 兼容你原来的调用风格openTab({ url, ..., tabError(tab, details), tabUpdated(tab, details) })
export function openTab(options) {
options = options && typeof options === 'object' ? options : {};
const url = options.url;
// openTab 任务对象:用对象绑定方法,减少重复参数
export function create_tab_task(url) {
const task = {
url,
latest: false,
top: 20,
left: 20,
width: 1440,
height: 900,
target: null,
active: true,
// 你期望的写法tab_task.on_updated = () => {}
on_error: null,
on_updated: null,
set_bounds(bounds) {
bounds = bounds && typeof bounds === 'object' ? bounds : {};
if (Object.prototype.hasOwnProperty.call(bounds, 'top')) this.top = bounds.top;
if (Object.prototype.hasOwnProperty.call(bounds, 'left')) this.left = bounds.left;
if (Object.prototype.hasOwnProperty.call(bounds, 'width')) this.width = bounds.width;
if (Object.prototype.hasOwnProperty.call(bounds, 'height')) this.height = bounds.height;
return this;
},
set_target(target) {
this.target = target || null;
return this;
},
set_latest(latest) {
this.latest = !!latest;
return this;
},
set_active(active) {
this.active = active !== false;
return this;
},
async open_async() {
// 直接返回 tab 对象(带 remove / execute_script
const { tab } = await open_tab(this.url, { active: this.active !== false });
return tab;
},
};
const tabError = typeof options.tabError === 'function' ? options.tabError : () => void 0;
const tabUpdated = typeof options.tabUpdated === 'function' ? options.tabUpdated : () => void 0;
if (!url) {
tabError(null, { error: 'url 不能为空' });
return;
}
open_tab(url, { active: options.active !== false })
.then(({ tab }) => {
tabUpdated(tab, { status: 'complete' });
})
.catch((err) => {
tabError(null, { error: err.message || String(err) });
});
return task;
}

View File

@@ -44,11 +44,6 @@
<div class="card_title">响应</div>
<pre id="last_response" class="pre"></pre>
</div>
<div class="card span2">
<div class="card_title">推送事件(实时)</div>
<pre id="event_log" class="pre pre_scroll"></pre>
</div>
</div>
</div>

View File

@@ -4,8 +4,8 @@ const btn_run_el = document.getElementById('btn_run');
const btn_clear_el = document.getElementById('btn_clear');
const btn_bg_reload_el = document.getElementById('btn_bg_reload');
const last_response_el = document.getElementById('last_response');
const event_log_el = document.getElementById('event_log');
let actions_meta = {};
const ui_state = { events: [] };
function now_time() {
const d = new Date();
@@ -25,34 +25,40 @@ function set_last_response(obj) {
last_response_el.textContent = JSON.stringify(obj, null, 2);
}
function append_event_line(obj) {
const line = `[${now_time()}] ${JSON.stringify(obj)}`;
event_log_el.textContent = (event_log_el.textContent ? event_log_el.textContent + '\n' : '') + line;
event_log_el.scrollTop = event_log_el.scrollHeight;
function render_state() {
// 只在一个窗口展示所有数据,方便查看
last_response_el.textContent = JSON.stringify(ui_state, null, 2);
}
function push_event(obj) {
ui_state.events.push({ ts: now_time(), ...obj });
// 简单限长,避免无限增长
if (ui_state.events.length > 300) {
ui_state.events.splice(0, ui_state.events.length - 300);
}
render_state();
}
btn_run_el.addEventListener('click', () => {
const action = action_name_el.value;
const params = safe_json_parse(action_params_el.value || '{}');
append_event_line({ type: 'call', action, params });
set_last_response({ running: true, action, params });
push_event({ type: 'call', action, params });
chrome.runtime.sendMessage({ action, data: params }, (res) => {
set_last_response(res);
append_event_line({ type: 'response', action, res });
push_event({ type: 'reply', action, res });
});
});
btn_clear_el.addEventListener('click', () => {
last_response_el.textContent = '';
event_log_el.textContent = '';
ui_state.events = [];
render_state();
});
btn_bg_reload_el.addEventListener('click', () => {
append_event_line({ type: 'ui', action: 'reload_background' });
push_event({ type: 'ui', action: 'reload_background' });
chrome.runtime.sendMessage({ action: 'reload_background', data: {} }, (res) => {
append_event_line({ type: 'ui', action: 'reload_background_done', res });
push_event({ type: 'ui', action: 'reload_background_done', res });
});
});
@@ -103,17 +109,17 @@ chrome.runtime.onMessage.addListener((message) => {
}
if (message.event_name === 'push') {
append_event_line({ type: 'push', payload: message.payload });
push_event({ type: 'push', payload: message.payload });
return;
}
if (message.event_name === 'request') {
append_event_line({ type: 'request', payload: message.payload });
push_event({ type: 'request', payload: message.payload });
return;
}
if (message.event_name === 'response') {
append_event_line({ type: 'bg_response', payload: message.payload });
push_event({ type: 'bg_response', payload: message.payload });
return;
}
});
@@ -126,4 +132,5 @@ chrome.runtime.sendMessage({ action: 'meta_actions', data: {} }, (res) => {
}
});
append_event_line({ type: 'ready', hint: '点击右侧/上方执行按钮开始' });
render_state();
push_event({ type: 'ready', hint: '点击右侧/上方执行按钮开始' });