diff --git a/mv2_simple_crx/src/actions/amazon.js b/mv2_simple_crx/src/actions/amazon.js new file mode 100644 index 0000000..1feaf04 --- /dev/null +++ b/mv2_simple_crx/src/actions/amazon.js @@ -0,0 +1,197 @@ +// amazon_top_list:Amazon TOP 榜单抓取(Best Sellers / New Releases / Movers & Shakers) + +import { openTab } from '../libs/tabs.js'; +import { execute_script } from '../libs/inject.js'; + +export function amazon_search_list(data, sendResponse) { + return new Promise((resolve, reject) => { + const category_keyword = (data && data.category_keyword) ? String(data.category_keyword).trim() : ''; + + const keyword = category_keyword || '野餐包'; + + // 用你给的 URL 作为模板,只替换 k 参数,其它参数保持一致 + const default_url = (() => { + const u = new URL('https://www.amazon.com/s'); + u.searchParams.set('k', keyword); + u.searchParams.set('__mk_zh_CN', '亚马逊网站'); + u.searchParams.set('crid', 'ZKNCI4U8BBAP'); + u.searchParams.set('sprefix', '野餐bao,caps,388'); + u.searchParams.set('ref', 'nb_sb_noss'); + return u.toString(); + })(); + + const url = (data && data.url) ? String(data.url).trim() : default_url; + + let times = 0; + + openTab({ + url, + latest: false, + top: 20, + left: 20, + width: 1440, + height: 900, + target: '__amazon_search_list', + + tabError(tab, details) { + const result = { + code: 30, + status: false, + message: (details && (details.message || details.statusLine || details.error)) || 'tab error', + data: null, + documentURI: details && details.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[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); + } + reject(err); + }); + }, + }); + }); +} + +amazon_search_list.desc = 'Amazon 搜索结果列表抓取(DOM 解析)'; +amazon_search_list.params = { + url: { + type: 'string', + desc: '可选,传入完整搜索 URL(不传则按 category_keyword 生成)', + default: '', + }, + category_keyword: { + type: 'string', + desc: '分类关键词', + default: '野餐包', + }, +}; + + diff --git a/mv2_simple_crx/src/actions/zhipu.js b/mv2_simple_crx/src/actions/zhipu.js deleted file mode 100644 index 977cbae..0000000 --- a/mv2_simple_crx/src/actions/zhipu.js +++ /dev/null @@ -1,138 +0,0 @@ -// zhipu_query_position_page:按你原项目的回调风格实现(参考 @zhipu.js 15-29) - -import { openTab } from '../libs/tabs.js'; -import { execute_script } from '../libs/inject.js'; - -function injected_zhipu_query_position_page() { - // 运行在 content 隔离环境:监听页面派发的 __REQUEST_DONE - // 命中接口后用 chrome.runtime.sendMessage 回传给 background - const action = 'zhipu_query_position_page'; - - function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); - } - - function send(action_name, data) { - try { - chrome.runtime.sendMessage({ type: 'push', action: action_name, data }); - } catch (e) { - // ignore - } - } - - if (!window.__zhipu_job_posts_listener__) { - window.__zhipu_job_posts_listener__ = true; - window.addEventListener('__REQUEST_DONE', (event) => { - - const detail = event && event.detail ? event.detail : {}; - const url = detail.url || ''; - if (typeof url === 'string' && url.includes('/api/v1/search/job/posts')) { - send('zhipu_job_posts', detail.json); - } - }); - } - - async function auto_flip_pages() { - await sleep(1500); - - while (true) { - const next_li = document.querySelector('.atsx-pagination-next'); - if (!next_li) { - send(action, { is_end: true }); - return; - } - - const disabled = - next_li.getAttribute('aria-disabled') === 'true' || - next_li.classList.contains('atsx-pagination-disabled'); - - if (disabled) { - send(action, { is_end: true }); - return; - } - - const next_btn = next_li.querySelector('.atsx-pagination-item-link'); - if (!next_btn) { - send(action, { is_end: true }); - return; - } - - next_btn.click(); - await sleep(2000); - } - } - - if (!location.href.startsWith('https://zhipu-ai.jobs.feishu.cn/referral/position')) { - send(action, { error: '未定位到智谱职位列表页', url: location.href }); - return; - } - - auto_flip_pages().catch((err) => { - send(action, { error: (err && err.message) || String(err) }); - }); -} - -export function zhipu_query_position_page(data, sendResponse) { - return new Promise((resolve, reject) => { - const url = 'https://zhipu-ai.jobs.feishu.cn/referral/position'; - - let times = 0; - - openTab({ - url, - latest: false, - top: 20, - left: 20, - width: 1440, - height: 900, - target: '__zhipu_query_position_page', - - tabError(tab, details) { - const result = { - code: 30, - status: false, - message: details && (details.message || details.statusLine || details.error) || 'tab error', - data: null, - documentURI: details && details.url, - }; - - sendResponse && sendResponse({ action: 'zhipu_query_position_page', 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, injected_zhipu_query_position_page, 'document_idle') - .then(() => { - const result = { code: 0, status: true, message: 'ok', data: { tab_id, url } }; - sendResponse && sendResponse({ action: 'zhipu_query_position_page', data: result }); - sendResponse && sendResponse.log && sendResponse.log(result); - - // 这里不自动关 tab,方便你调试;要关就打开下一行 - // tab.remove(3500); - - resolve({ tab_id, url }); - }) - .catch((err) => { - if (tab && tab.remove) { - tab.remove(500); - } - reject(err); - }); - }, - }); - }); -} diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js index 79ffb46..3af9002 100644 --- a/mv2_simple_crx/src/background/index.js +++ b/mv2_simple_crx/src/background/index.js @@ -1,9 +1,23 @@ -import { zhipu_query_position_page } from '../actions/zhipu.js'; + +import { amazon_search_list } from '../actions/amazon.js'; const actions = { - zhipu_query_position_page, + amazon_search_list, }; +function list_actions_meta() { + const meta = {}; + Object.keys(actions).forEach((name) => { + const fn = actions[name]; + meta[name] = { + name, + desc: fn && fn.desc ? fn.desc : '', + params: fn && fn.params ? fn.params : {}, + }; + }); + return meta; +} + function create_action_send_response(sender) { const fn = (payload) => { emit_ui_event('push', { type: 'reply', ...payload, sender }); @@ -61,6 +75,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return; } + // UI 获取 action 元信息(用于下拉/默认参数) + if (message.action === 'meta_actions') { + sendResponse({ ok: true, data: list_actions_meta() }); + return; + } + + // UI 刷新后台(重启 background page) + if (message.action === 'reload_background') { + sendResponse({ ok: true }); + setTimeout(() => { + location.reload(); + }, 50); + return; + } + const fn = actions[message.action]; if (!fn) { sendResponse({ ok: false, error: '未知 action: ' + message.action }); diff --git a/mv2_simple_crx/src/libs/inject.js b/mv2_simple_crx/src/libs/inject.js index f20642d..96c4a30 100644 --- a/mv2_simple_crx/src/libs/inject.js +++ b/mv2_simple_crx/src/libs/inject.js @@ -1,9 +1,17 @@ // executeScript:MV2 使用 chrome.tabs.executeScript function normalize_code(code) { + // 支持:直接传函数 if (typeof code === 'function') { return `(${code.toString()})();`; } + + // 支持:传 { 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; } diff --git a/mv2_simple_crx/src/ui/index.html b/mv2_simple_crx/src/ui/index.html index 10374e0..8f2c789 100644 --- a/mv2_simple_crx/src/ui/index.html +++ b/mv2_simple_crx/src/ui/index.html @@ -21,6 +21,8 @@ @@ -29,6 +31,7 @@
+
diff --git a/mv2_simple_crx/src/ui/index.js b/mv2_simple_crx/src/ui/index.js index 3250f07..81fc7c8 100644 --- a/mv2_simple_crx/src/ui/index.js +++ b/mv2_simple_crx/src/ui/index.js @@ -2,8 +2,10 @@ const action_name_el = document.getElementById('action_name'); const action_params_el = document.getElementById('action_params'); 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 = {}; function now_time() { const d = new Date(); @@ -47,6 +49,53 @@ btn_clear_el.addEventListener('click', () => { event_log_el.textContent = ''; }); +btn_bg_reload_el.addEventListener('click', () => { + append_event_line({ type: 'ui', action: 'reload_background' }); + chrome.runtime.sendMessage({ action: 'reload_background', data: {} }, (res) => { + append_event_line({ type: 'ui', action: 'reload_background_done', res }); + }); +}); + +function build_default_params(params_schema) { + const obj = {}; + if (!params_schema || typeof params_schema !== 'object') return obj; + Object.keys(params_schema).forEach((k) => { + const item = params_schema[k]; + if (item && typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, 'default')) { + obj[k] = item.default; + } + }); + return obj; +} + +function refresh_params_editor() { + const name = action_name_el.value; + const meta = actions_meta[name]; + if (!meta) return; + const defaults = build_default_params(meta.params); + action_params_el.value = JSON.stringify(defaults, null, 2); +} + +function refresh_action_select() { + const current = action_name_el.value; + action_name_el.innerHTML = ''; + Object.keys(actions_meta).forEach((name) => { + const meta = actions_meta[name]; + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = meta && meta.desc ? `${name} - ${meta.desc}` : name; + action_name_el.appendChild(opt); + }); + if (current && actions_meta[current]) { + action_name_el.value = current; + } + refresh_params_editor(); +} + +action_name_el.addEventListener('change', () => { + refresh_params_editor(); +}); + // background -> UI 的推送 chrome.runtime.onMessage.addListener((message) => { if (!message || message.channel !== 'ui_event') { @@ -69,4 +118,12 @@ chrome.runtime.onMessage.addListener((message) => { } }); +// 初始化:拉取 action 元信息,生成下拉 + 默认参数 +chrome.runtime.sendMessage({ action: 'meta_actions', data: {} }, (res) => { + if (res && res.ok && res.data) { + actions_meta = res.data; + refresh_action_select(); + } +}); + append_event_line({ type: 'ready', hint: '点击右侧/上方执行按钮开始' });