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