1
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
// amazon_top_list:Amazon 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
75
mv2_simple_crx/src/injected/amazon_search_list.js
Normal file
75
mv2_simple_crx/src/injected/amazon_search_list.js
Normal 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 });
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
// executeScript:MV2 使用 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)});`;
|
||||
}
|
||||
return `(${fn.toString()})();`;
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// 支持:传 { 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 code;
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// openTab:MV2 版本(极简 + 回调风格)
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '点击右侧/上方执行按钮开始' });
|
||||
|
||||
Reference in New Issue
Block a user