From db1d70a46ef59a9e86775115e4b062fbc5840888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Tue, 17 Mar 2026 16:40:51 +0800 Subject: [PATCH] init --- 1.md | 40 ++++++ mv2_simple_crx/src/actions/zhipu.js | 138 +++++++++++++++++++ mv2_simple_crx/src/background/index.html | 10 ++ mv2_simple_crx/src/background/index.js | 88 ++++++++++++ mv2_simple_crx/src/content/content.js | 166 +++++++++++++++++++++++ mv2_simple_crx/src/libs/inject.js | 49 +++++++ mv2_simple_crx/src/libs/tabs.js | 78 +++++++++++ mv2_simple_crx/src/manifest.json | 28 ++++ mv2_simple_crx/src/popup/popup.html | 18 +++ mv2_simple_crx/src/popup/popup.js | 16 +++ mv2_simple_crx/src/ui/index.css | 158 +++++++++++++++++++++ mv2_simple_crx/src/ui/index.html | 54 ++++++++ mv2_simple_crx/src/ui/index.js | 72 ++++++++++ 13 files changed, 915 insertions(+) create mode 100644 1.md create mode 100644 mv2_simple_crx/src/actions/zhipu.js create mode 100644 mv2_simple_crx/src/background/index.html create mode 100644 mv2_simple_crx/src/background/index.js create mode 100644 mv2_simple_crx/src/content/content.js create mode 100644 mv2_simple_crx/src/libs/inject.js create mode 100644 mv2_simple_crx/src/libs/tabs.js create mode 100644 mv2_simple_crx/src/manifest.json create mode 100644 mv2_simple_crx/src/popup/popup.html create mode 100644 mv2_simple_crx/src/popup/popup.js create mode 100644 mv2_simple_crx/src/ui/index.css create mode 100644 mv2_simple_crx/src/ui/index.html create mode 100644 mv2_simple_crx/src/ui/index.js diff --git a/1.md b/1.md new file mode 100644 index 0000000..8a6e562 --- /dev/null +++ b/1.md @@ -0,0 +1,40 @@ +核心目标: 通过监控全平台(Amazon, TikTok, Temu, Shopee)的核心数据,利用实时曲线和趋势预测模型,实现“发现爆款 -> 数据验证 -> 决策跟卖”的闭环。 + +--- +1. 业务需求范围 +1.1 覆盖平台与站点 +- Amazon: 全球主流站(US, UK, DE, JP)。 +- TikTok Shop: 东南亚及美区(侧重短视频带货趋势)。 +- Temu: 全球站(侧重价格战与低价爆款)。 +- Shopee: 东南亚市场(侧重品类精细化运营)。 +1.2 监控核心类目(户外生活/Outdoor Lifestyle) +- 核心类目: 野餐包、帐篷、地垫、户外产品、防水包。 +- 扩展逻辑: 支持基于关键词和类目 ID 的自定义扩展。 + +--- +2. 核心功能需求 +2.1 多维数据抓取模块 (Data Scraper) +- TOP榜单抓取: 每日/每小时抓取 Bestsellers、New Releases、Movers & Shakers 榜单。 +- List/品类详情: 提取产品标题、价格、SKU、品牌、变体、上架时间、物流方式。 +- 舆情监控: 抓取售后留言和评论(Reviews),需支持提取“差评关键词”以发现现有产品的痛点。 +- 流量监控: TikTok 关联视频点赞数、热度趋势;Amazon 搜索排名(BSR)波动。 +2.2 BI 数据处理与分析 (Processing & BI) +- 实时曲线绘制: * BSR 曲线: 监控产品排名的历史波动,判断是“昙花一现”还是“稳步上升”。 + - 价格曲线: 监控价格变动(Temu 价格挤压或 Amazon 季节性调价)。 + - 销量预测曲线: 基于排名和库存变动,拟合预测未来 7-30 天的销量。 +- BI 嗅探逻辑: + - 竞品对标: 自动匹配相似款,计算同质化程度。 + - 舆情聚合: 利用 NLP 聚类,提炼该品类下消费者最不满意的 3 个点(如:防水包不防漏、帐篷难搭建),作为跟卖后的迭代改进方向。 +2.3 趋势预测与决策辅助 (Trend Engine) +- 趋势判定算法: + - 潜伏期识别: 识别刚上架且排名异常快速上升的产品(Potential Winners)。 + - 爆发力模型: 结合 TikTok 热度指数 + 平台搜索量增长率。 +- 决策建议: + - 跟卖指数: 综合竞争程度、利润空间和供应链难度,给出品类跟卖分数。 + - 生命周期预警: 提示该品类是否已进入红海或衰退期(如户外产品明显的季节性)。 + +--- +3. 预期成果 (Outcome) +- 可视化看板: 每天自动推送一份“户外品类异动日报”。 +- 爆款预警: 当监测到“防水包”类目下某新品 3 天内排名上升超过 50% 时,系统触发钉钉/邮件预警。 +- 决策支持: 提供该款产品的“避坑指南”(基于竞品售后差评分析)。 \ No newline at end of file diff --git a/mv2_simple_crx/src/actions/zhipu.js b/mv2_simple_crx/src/actions/zhipu.js new file mode 100644 index 0000000..977cbae --- /dev/null +++ b/mv2_simple_crx/src/actions/zhipu.js @@ -0,0 +1,138 @@ +// 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.html b/mv2_simple_crx/src/background/index.html new file mode 100644 index 0000000..a63ba62 --- /dev/null +++ b/mv2_simple_crx/src/background/index.html @@ -0,0 +1,10 @@ + + + + + mv2_simple_crx background + + + + + diff --git a/mv2_simple_crx/src/background/index.js b/mv2_simple_crx/src/background/index.js new file mode 100644 index 0000000..79ffb46 --- /dev/null +++ b/mv2_simple_crx/src/background/index.js @@ -0,0 +1,88 @@ +import { zhipu_query_position_page } from '../actions/zhipu.js'; + +const actions = { + zhipu_query_position_page, +}; + +function create_action_send_response(sender) { + const fn = (payload) => { + emit_ui_event('push', { type: 'reply', ...payload, sender }); + }; + fn.log = (payload) => { + emit_ui_event('push', { type: 'log', action: 'log', data: payload, sender }); + }; + return fn; +} + +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({ + channel: 'ui_event', + event_name, + payload, + ts: Date.now(), + }); +} + +chrome.browserAction.onClicked.addListener(() => { + chrome.tabs.create({ url: ui_page_url, active: true }); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message) { + return; + } + + // UI 自己发出来的事件,background 不处理 + if (message.channel === 'ui_event') { + return; + } + + // content -> background 的推送消息(通用) + if (message.type === 'push') { + emit_ui_event('push', { + type: 'push', + action: message.action, + data: message.data, + sender, + }); + return; + } + + // UI -> background 的 action 调用 + if (!message.action) { + sendResponse && sendResponse({ ok: false, error: '缺少 action' }); + return; + } + + const fn = actions[message.action]; + if (!fn) { + sendResponse({ ok: false, error: '未知 action: ' + message.action }); + return; + } + + const request_id = `${Date.now()}_${Math.random().toString().slice(2)}`; + emit_ui_event('request', { type: 'request', request_id, action: message.action, data: message.data || {}, sender }); + + const action_send_response = create_action_send_response(sender); + + Promise.resolve() + .then(() => fn(message.data || {}, action_send_response)) + .then((res) => { + emit_ui_event('response', { type: 'response', request_id, ok: true, data: res, sender }); + sendResponse({ ok: true, data: res, request_id }); + }) + .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; +}); diff --git a/mv2_simple_crx/src/content/content.js b/mv2_simple_crx/src/content/content.js new file mode 100644 index 0000000..f07f7c7 --- /dev/null +++ b/mv2_simple_crx/src/content/content.js @@ -0,0 +1,166 @@ +// content script:在 document_start inline 注入 RequestWatcher +// 目标:页面里触发 XHR/fetch 时派发 __REQUEST_DONE + +(() => { + function inject_inline(fn) { + const el = document.createElement('script'); + el.type = 'text/javascript'; + el.textContent = `(${fn.toString()})();`; + (document.head || document.documentElement).appendChild(el); + el.parentNode && el.parentNode.removeChild(el); + } + + function request_watcher() { + const F = window.Function.__proto__; + if (F.__RequestWatcher) return; + + const __addEventListener = F.__addEventListener = F.__addEventListener || window.addEventListener; + const __xhr_open = F.__xhr_open = F.__xhr_open || window.XMLHttpRequest.prototype.open; + const __xhr_send = F.__xhr_send = F.__xhr_send || window.XMLHttpRequest.prototype.send; + const __fetch = F.__fetch = F.__fetch || window.fetch; + + if (F.__fetch) { + F.__fetch = F.__fetch.bind(window); + } + + const tagList = []; + + __addEventListener('__REQUEST_TAG', function (event) { + if (event && event.detail && event.detail.TAG) { + tagList.push(event.detail.TAG); + } + }); + + function onOpen(method, url, async, user, password) { + this.__detail = {}; + if (tagList.length) this.__detail.TAG = tagList.pop(); + if (method !== undefined) this.__detail.method = method; + if (url !== undefined) this.__detail.url = url; + if (async !== undefined) this.__detail.async = async; + if (user !== undefined) this.__detail.user = user; + if (password !== undefined) this.__detail.password = password; + return __xhr_open.apply(this, arguments); + } + + function onSend(data) { + this.__detail = this.__detail || {}; + this.__detail.data = data; + this.addEventListener('readystatechange', onReadyStateChange); + return __xhr_send.apply(this, arguments); + } + + function onReadyStateChange() { + if (this.readyState === 4) { + let detail = { ...this.__detail }; + delete this.__detail; + + detail.url = this.responseURL || detail.url || null; + detail.readyState = this.readyState; + detail.status = this.status; + detail.statusText = this.statusText; + detail.ok = this.status >= 200 && this.status < 300; + detail.text = null; + detail.json = null; + detail.responseContentType = null; + + try { + detail.text = this.responseText || null; + detail.responseContentType = this.getResponseHeader('content-type')?.toLowerCase() || null; + if ( + detail.responseContentType && + (detail.responseContentType.includes('application/json') || detail.responseContentType.includes('text/json')) + ) { + detail.json = JSON.parse(detail.text || 'null'); + } + } catch (ex) { + // ignore + } + + // 关键:只派发纯数据,避免跨上下文被裁剪导致“看起来不一样” + const payload = { + TAG: detail.TAG, + method: detail.method, + url: detail.url, + status: detail.status, + statusText: detail.statusText, + ok: detail.ok, + responseContentType: detail.responseContentType, + text: detail.text, + json: detail.json, + data: detail.data, + }; + + window.dispatchEvent(new CustomEvent('__REQUEST_DONE', { detail: payload })); + detail = null; + } + } + + window.XMLHttpRequest.prototype.open = onOpen; + window.XMLHttpRequest.prototype.send = onSend; + + // fetch(用 Proxy,避免 this/参数形态问题) + if (typeof __fetch === 'function') { + window.fetch = new Proxy(__fetch, { + apply(target, thisArg, args) { + const [url, options] = args; + let detail = {}; + if (tagList.length) detail.TAG = tagList.pop(); + if (url !== undefined) detail.url = url; + if (options && typeof options === 'object') { + detail.options = options; + if (options.method !== undefined) detail.method = options.method; + if (options.body !== undefined) detail.data = options.body; + } + + return target(...args) + .then(async (response) => { + detail.ok = true; + detail.message = 'ok'; + const cloneResponse = response.clone(); + detail.status = cloneResponse.status; + detail.statusText = cloneResponse.statusText; + 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 && + (detail.responseContentType.includes('application/json') || detail.responseContentType.includes('text/json')) + ) { + detail.json = await response.clone().json(); + } + return response; + }) + .catch((error) => { + detail.ok = false; + detail.error = error; + detail.message = error.message; + throw error; + }) + .finally(() => { + const payload = { + TAG: detail.TAG, + method: detail.method, + url: detail.url, + status: detail.status, + statusText: detail.statusText, + ok: detail.ok, + responseContentType: detail.responseContentType, + text: detail.text, + json: detail.json, + data: detail.data, + }; + + window.dispatchEvent(new CustomEvent('__REQUEST_DONE', { detail: payload })); + detail = null; + }); + }, + }); + } + + F.__RequestWatcher = true; + } + + inject_inline(request_watcher); +})(); diff --git a/mv2_simple_crx/src/libs/inject.js b/mv2_simple_crx/src/libs/inject.js new file mode 100644 index 0000000..f20642d --- /dev/null +++ b/mv2_simple_crx/src/libs/inject.js @@ -0,0 +1,49 @@ +// executeScript:MV2 使用 chrome.tabs.executeScript + +function normalize_code(code) { + if (typeof code === 'function') { + return `(${code.toString()})();`; + } + return code; +} + +export function execute_script(tab_id, code, run_at) { + run_at = run_at || 'document_idle'; + code = normalize_code(code); + + return new Promise((resolve, reject) => { + chrome.tabs.executeScript( + tab_id, + { + code, + runAt: run_at, + }, + (result) => { + if (chrome.runtime.lastError) { + return reject(new Error(chrome.runtime.lastError.message)); + } + resolve(result); + }, + ); + }); +} + +export function inject_file(tab_id, file, run_at) { + run_at = run_at || 'document_idle'; + + return new Promise((resolve, reject) => { + chrome.tabs.executeScript( + tab_id, + { + file, + runAt: run_at, + }, + () => { + if (chrome.runtime.lastError) { + return reject(new Error(chrome.runtime.lastError.message)); + } + resolve(true); + }, + ); + }); +} diff --git a/mv2_simple_crx/src/libs/tabs.js b/mv2_simple_crx/src/libs/tabs.js new file mode 100644 index 0000000..49cdd9e --- /dev/null +++ b/mv2_simple_crx/src/libs/tabs.js @@ -0,0 +1,78 @@ +// openTab:MV2 版本(极简 + 回调风格) + +function attach_tab_helpers(tab) { + if (!tab) return tab; + + tab.remove = function remove(delay_ms) { + delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0; + setTimeout(() => { + chrome.tabs.remove(tab.id, () => void 0); + }, Math.max(0, delay_ms)); + }; + + return tab; +} + +export function open_tab(url, options) { + // 保留原本 Promise 版本(内部复用) + options = options && typeof options === 'object' ? options : {}; + + return new Promise((resolve, reject) => { + chrome.tabs.create( + { + url: 'about:blank', + active: options.active !== false, + }, + (tab) => { + if (chrome.runtime.lastError) { + return reject(new Error(chrome.runtime.lastError.message)); + } + if (!tab || !tab.id) { + return reject(new Error('tab 创建失败')); + } + + const tab_id = tab.id; + + const on_updated = (updated_tab_id, change_info, updated_tab) => { + if (updated_tab_id !== tab_id) return; + if (change_info.status !== 'complete') return; + + chrome.tabs.onUpdated.removeListener(on_updated); + resolve({ tab_id, tab: attach_tab_helpers(updated_tab) }); + }; + + chrome.tabs.onUpdated.addListener(on_updated); + chrome.tabs.update(tab_id, { url }); + }, + ); + }); +} + +export function close_tab(tab_id, delay_ms) { + delay_ms = Number.isFinite(delay_ms) ? delay_ms : 0; + setTimeout(() => { + chrome.tabs.remove(tab_id, () => void 0); + }, 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; + + 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) }); + }); +} diff --git a/mv2_simple_crx/src/manifest.json b/mv2_simple_crx/src/manifest.json new file mode 100644 index 0000000..a198f2e --- /dev/null +++ b/mv2_simple_crx/src/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 2, + "name": "mv2_simple_crx", + "version": "0.1.0", + "description": "MV2 极简骨架:openTab + executeScript + __REQUEST_DONE 监听", + "permissions": [ + "tabs", + "" + ], + "background": { + "page": "background/index.html", + "persistent": true + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/content.js"], + "run_at": "document_start", + "all_frames": false + } + ], + "web_accessible_resources": [ + "content/request_watcher.js" + ], + "browser_action": { + "default_title": "mv2_simple_crx" + } +} diff --git a/mv2_simple_crx/src/popup/popup.html b/mv2_simple_crx/src/popup/popup.html new file mode 100644 index 0000000..f46cbd5 --- /dev/null +++ b/mv2_simple_crx/src/popup/popup.html @@ -0,0 +1,18 @@ + + + + + + mv2_simple_crx + + + + +

+    
+  
+
diff --git a/mv2_simple_crx/src/popup/popup.js b/mv2_simple_crx/src/popup/popup.js
new file mode 100644
index 0000000..7f65f4d
--- /dev/null
+++ b/mv2_simple_crx/src/popup/popup.js
@@ -0,0 +1,16 @@
+const out = document.getElementById('out');
+const btn = document.getElementById('btn');
+
+function set_out(obj) {
+  out.textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
+}
+
+btn.addEventListener('click', () => {
+  set_out('执行中...');
+  chrome.runtime.sendMessage(
+    { action: 'zhipu_query_position_page', data: {} },
+    (res) => {
+      set_out(res);
+    },
+  );
+});
diff --git a/mv2_simple_crx/src/ui/index.css b/mv2_simple_crx/src/ui/index.css
new file mode 100644
index 0000000..418519b
--- /dev/null
+++ b/mv2_simple_crx/src/ui/index.css
@@ -0,0 +1,158 @@
+:root {
+  --bg: #0b1220;
+  --card: #121b2d;
+  --card2: #0f1728;
+  --text: #e7eefc;
+  --muted: #9db0d1;
+  --border: rgba(255, 255, 255, 0.08);
+  --primary: #4f8cff;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+  background: radial-gradient(1200px 700px at 20% 10%, rgba(79, 140, 255, 0.25), transparent 60%),
+              radial-gradient(900px 600px at 90% 20%, rgba(167, 139, 250, 0.18), transparent 55%),
+              var(--bg);
+  color: var(--text);
+}
+
+.app {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 18px;
+}
+
+.header {
+  padding: 14px 14px 10px;
+}
+
+.title {
+  font-size: 20px;
+  font-weight: 700;
+}
+
+.sub {
+  margin-top: 6px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+}
+
+.card {
+  border: 1px solid var(--border);
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+  backdrop-filter: blur(8px);
+  border-radius: 12px;
+  overflow: hidden;
+}
+
+.card_title {
+  padding: 12px 14px;
+  border-bottom: 1px solid var(--border);
+  background: rgba(0, 0, 0, 0.15);
+  font-weight: 600;
+  font-size: 13px;
+}
+
+.form {
+  padding: 14px;
+}
+
+.label {
+  display: block;
+  margin: 10px 0 6px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.input,
+.textarea {
+  width: 100%;
+  border: 1px solid var(--border);
+  background: rgba(0, 0, 0, 0.25);
+  color: var(--text);
+  border-radius: 10px;
+  padding: 10px 10px;
+  outline: none;
+}
+
+.textarea {
+  min-height: 160px;
+  resize: vertical;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.row {
+  display: flex;
+  gap: 10px;
+  margin-top: 12px;
+}
+
+.btn {
+  border: 1px solid var(--border);
+  background: rgba(0, 0, 0, 0.25);
+  color: var(--text);
+  border-radius: 10px;
+  padding: 10px 12px;
+  cursor: pointer;
+  font-weight: 600;
+}
+
+.btn.primary {
+  border-color: rgba(79, 140, 255, 0.45);
+  background: rgba(79, 140, 255, 0.18);
+}
+
+.btn:hover {
+  border-color: rgba(255, 255, 255, 0.18);
+}
+
+.hint {
+  margin-top: 12px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.hint code {
+  color: #c7d2fe;
+}
+
+.pre {
+  margin: 0;
+  padding: 14px;
+  background: rgba(0, 0, 0, 0.22);
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  font-size: 12px;
+  line-height: 1.5;
+  min-height: 260px;
+  color: #dbeafe;
+}
+
+.pre_scroll {
+  max-height: 520px;
+  overflow: auto;
+}
+
+.span2 {
+  grid-column: 1 / span 2;
+}
+
+@media (max-width: 980px) {
+  .grid {
+    grid-template-columns: 1fr;
+  }
+  .span2 {
+    grid-column: 1 / span 1;
+  }
+}
diff --git a/mv2_simple_crx/src/ui/index.html b/mv2_simple_crx/src/ui/index.html
new file mode 100644
index 0000000..10374e0
--- /dev/null
+++ b/mv2_simple_crx/src/ui/index.html
@@ -0,0 +1,54 @@
+
+
+  
+    
+    
+    mv2_simple_crx 控制台
+    
+  
+  
+    
+
+
mv2_simple_crx 控制台
+
选择 action,填参数,执行并查看响应/推送事件
+
+ +
+
+
调用
+ +
+ + + + + + +
+ + +
+ +
+ 说明:执行后会显示「响应」,同时如果页面内监听到 __REQUEST_DONE 命中接口,会在「推送事件」里持续追加。 +
+
+
+ +
+
响应
+

+        
+ +
+
推送事件(实时)
+

+        
+
+
+ + + + diff --git a/mv2_simple_crx/src/ui/index.js b/mv2_simple_crx/src/ui/index.js new file mode 100644 index 0000000..3250f07 --- /dev/null +++ b/mv2_simple_crx/src/ui/index.js @@ -0,0 +1,72 @@ +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 last_response_el = document.getElementById('last_response'); +const event_log_el = document.getElementById('event_log'); + +function now_time() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function safe_json_parse(text) { + try { + return JSON.parse(text); + } catch (e) { + return { __parse_error: e.message, __raw: text }; + } +} + +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; +} + +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 }); + + chrome.runtime.sendMessage({ action, data: params }, (res) => { + set_last_response(res); + append_event_line({ type: 'response', action, res }); + }); +}); + +btn_clear_el.addEventListener('click', () => { + last_response_el.textContent = ''; + event_log_el.textContent = ''; +}); + +// background -> UI 的推送 +chrome.runtime.onMessage.addListener((message) => { + if (!message || message.channel !== 'ui_event') { + return; + } + + if (message.event_name === 'push') { + append_event_line({ type: 'push', payload: message.payload }); + return; + } + + if (message.event_name === 'request') { + append_event_line({ type: 'request', payload: message.payload }); + return; + } + + if (message.event_name === 'response') { + append_event_line({ type: 'bg_response', payload: message.payload }); + return; + } +}); + +append_event_line({ type: 'ready', hint: '点击右侧/上方执行按钮开始' });