init
This commit is contained in:
40
1.md
Normal file
40
1.md
Normal file
@@ -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% 时,系统触发钉钉/邮件预警。
|
||||
- 决策支持: 提供该款产品的“避坑指南”(基于竞品售后差评分析)。
|
||||
138
mv2_simple_crx/src/actions/zhipu.js
Normal file
138
mv2_simple_crx/src/actions/zhipu.js
Normal file
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
10
mv2_simple_crx/src/background/index.html
Normal file
10
mv2_simple_crx/src/background/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>mv2_simple_crx background</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
88
mv2_simple_crx/src/background/index.js
Normal file
88
mv2_simple_crx/src/background/index.js
Normal file
@@ -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;
|
||||
});
|
||||
166
mv2_simple_crx/src/content/content.js
Normal file
166
mv2_simple_crx/src/content/content.js
Normal file
@@ -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);
|
||||
})();
|
||||
49
mv2_simple_crx/src/libs/inject.js
Normal file
49
mv2_simple_crx/src/libs/inject.js
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
78
mv2_simple_crx/src/libs/tabs.js
Normal file
78
mv2_simple_crx/src/libs/tabs.js
Normal file
@@ -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) });
|
||||
});
|
||||
}
|
||||
28
mv2_simple_crx/src/manifest.json
Normal file
28
mv2_simple_crx/src/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "mv2_simple_crx",
|
||||
"version": "0.1.0",
|
||||
"description": "MV2 极简骨架:openTab + executeScript + __REQUEST_DONE 监听",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"page": "background/index.html",
|
||||
"persistent": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
18
mv2_simple_crx/src/popup/popup.html
Normal file
18
mv2_simple_crx/src/popup/popup.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mv2_simple_crx</title>
|
||||
<style>
|
||||
body { font-family: Arial, "Microsoft YaHei"; margin: 12px; }
|
||||
button { width: 100%; padding: 10px 12px; cursor: pointer; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; background: #f6f6f6; padding: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="btn">打开智谱职位页并监听</button>
|
||||
<pre id="out"></pre>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
mv2_simple_crx/src/popup/popup.js
Normal file
16
mv2_simple_crx/src/popup/popup.js
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
158
mv2_simple_crx/src/ui/index.css
Normal file
158
mv2_simple_crx/src/ui/index.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
54
mv2_simple_crx/src/ui/index.html
Normal file
54
mv2_simple_crx/src/ui/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mv2_simple_crx 控制台</title>
|
||||
<link rel="stylesheet" href="index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="header">
|
||||
<div class="title">mv2_simple_crx 控制台</div>
|
||||
<div class="sub">选择 action,填参数,执行并查看响应/推送事件</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card_title">调用</div>
|
||||
|
||||
<div class="form">
|
||||
<label class="label">方法名(action)</label>
|
||||
<select id="action_name" class="input">
|
||||
<option value="zhipu_query_position_page">zhipu_query_position_page</option>
|
||||
</select>
|
||||
|
||||
<label class="label">参数(JSON)</label>
|
||||
<textarea id="action_params" class="textarea" spellcheck="false">{}</textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="btn_run" class="btn primary">执行</button>
|
||||
<button id="btn_clear" class="btn">清空日志</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
说明:执行后会显示「响应」,同时如果页面内监听到 <code>__REQUEST_DONE</code> 命中接口,会在「推送事件」里持续追加。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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>
|
||||
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
72
mv2_simple_crx/src/ui/index.js
Normal file
72
mv2_simple_crx/src/ui/index.js
Normal file
@@ -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: '点击右侧/上方执行按钮开始' });
|
||||
Reference in New Issue
Block a user