From 6677ec5eeca7d22ef6fb6efe1617ccf6a5f84b2c Mon Sep 17 00:00:00 2001 From: zhu <1812073942@qq.com> Date: Sat, 9 May 2026 18:51:44 +0800 Subject: [PATCH] 1 --- manifest.config.ts | 2 +- package.json | 2 +- s.md | 188 ++++++++++++++++++++ src/background/index.ts | 15 +- src/background/service/crawlTask.ts | 5 + src/background/service/externalBridge.ts | 207 +++++++++++++++++++++++ tsconfig.tsbuildinfo | 2 +- 7 files changed, 414 insertions(+), 7 deletions(-) create mode 100644 s.md create mode 100644 src/background/service/externalBridge.ts diff --git a/manifest.config.ts b/manifest.config.ts index e260b6f..d556f45 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -29,7 +29,7 @@ export default defineManifest({ }, externally_connectable: { matches: [ - "http://localhost:3000/*", + "http://localhost/*", ] } }); diff --git a/package.json b/package.json index 068e1c9..1bd3a0f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vite-project", "private": true, - "version": "0.0.0", + "version": "0.1.4", "type": "module", "scripts": { "dev": "vite", diff --git a/s.md b/s.md new file mode 100644 index 0000000..8febc76 --- /dev/null +++ b/s.md @@ -0,0 +1,188 @@ +# 网站接入店闪扩展说明 + +这个扩展已经提供网站侧调用接口。网站点击“开始”时,可以让扩展执行和 popup 手动点击“立即爬取”一样的流程:打开新浏览器窗口、进入平台后台、抓取数据。抓取完成后,扩展会通过长连接把结果推回网站。 + +## 1. 先配置允许连接的网站域名 + +扩展的 `manifest.config.ts` 里有: + +```ts +externally_connectable: { + matches: [ + "http://localhost:3000/*", + ] +} +``` + +把你的网站域名加进去,例如: + +```ts +externally_connectable: { + matches: [ + "http://localhost:3000/*", + "https://your-site.com/*", + ] +} +``` + +改完扩展后需要重新 `pnpm run build`,并在 Chrome 扩展管理页重新加载扩展。 + +## 2. 网站侧需要知道扩展 ID + +Chrome 扩展管理页打开“开发者模式”,复制这个扩展的 ID: + +```ts +const EXTENSION_ID = "这里换成你的扩展ID"; +``` + +开发环境如果每次扩展 ID 变化,建议给扩展配置固定 key,或者每次复制新的 ID 到网站项目配置里。 + +## 3. 推荐的网站侧接入代码 + +网站页面加载后先建立长连接,用来接收扩展推送的进度和最终结果。 + +```ts +const EXTENSION_ID = "这里换成你的扩展ID"; + +type DianshanMessage = { + ok: boolean; + type?: string; + data?: { + state: any | null; + result: Record | null; + }; + error?: string; +}; + +let port: chrome.runtime.Port | null = null; + +export function connectDianshanExtension() { + port = chrome.runtime.connect(EXTENSION_ID, { name: "DIANSHAN_CRAWL" }); + + port.onMessage.addListener((message: DianshanMessage) => { + console.log("[dianshan]", message); + + if (message.type === "DIANSHAN_CRAWL_STATE") { + // 可选:更新网站上的进度 UI + return; + } + + if (message.type === "DIANSHAN_CRAWL_DONE") { + // 抓取完成,最终数据在 message.data.result + console.log("抓取结果", message.data?.result); + return; + } + + if (message.type === "DIANSHAN_CRAWL_FAILED") { + // 抓取失败,可展示 message.data.state.steps 里的失败原因 + console.error("抓取失败", message.data?.state); + return; + } + + if (message.type === "DIANSHAN_CRAWL_CANCELED" || message.type === "DIANSHAN_CRAWL_CLEARED") { + // 用户取消或任务被清空 + console.log("抓取已取消"); + } + }); + + port.onDisconnect.addListener(() => { + port = null; + }); +} +``` + +## 4. 网站点击“开始抓取” + +按钮点击时调用: + +```ts +export async function startDianshanCrawl(platformId = "Shopee") { + const response = await chrome.runtime.sendMessage(EXTENSION_ID, { + type: "DIANSHAN_START_CRAWL", + payload: { platformId }, + }); + + if (!response?.ok) { + throw new Error(response?.error ?? "启动抓取失败"); + } + + return response.data; +} +``` + +效果等同于用户打开 popup 后手动点击“立即爬取”。如果当前已经有 running/paused 的任务,扩展会直接返回当前任务,不会重复打开多个抓取窗口。 + +## 5. 查询当前状态 + +```ts +export async function getDianshanCrawlState() { + return chrome.runtime.sendMessage(EXTENSION_ID, { + type: "DIANSHAN_GET_CRAWL_STATE", + }); +} +``` + +## 6. 网站侧取消抓取 + +```ts +export async function cancelDianshanCrawl() { + return chrome.runtime.sendMessage(EXTENSION_ID, { + type: "DIANSHAN_CANCEL_CRAWL", + }); +} +``` + +取消后扩展会清空 `crawlTaskState`,并关闭扩展自动打开的浏览器窗口。 + +## 7. 返回数据结构 + +长连接收到 `DIANSHAN_CRAWL_DONE` 时,数据大致是: + +```ts +{ + ok: true, + type: "DIANSHAN_CRAWL_DONE", + data: { + state: { + id: "Shopee-...", + platformId: "Shopee", + platformName: "Shopee 后台", + status: "completed", + steps: [ + { + name: "数据看板", + uniqueKey: "databoard", + status: "success", + result: {} + } + ] + }, + result: { + databoard: { + name: "数据看板", + status: "success", + result: {} + }, + adscenter: { + name: "广告中心", + status: "success", + result: {} + } + } + } +} +``` + +网站项目里一般用 `message.data.result` 入库或展示即可;如果要展示进度,用 `message.data.state.steps`。 + +## 8. 最小使用流程 + +```ts +connectDianshanExtension(); + +document.querySelector("#start")?.addEventListener("click", async () => { + await startDianshanCrawl("Shopee"); +}); +``` + +注意:网站必须运行在 `externally_connectable.matches` 配置过的域名下,否则 Chrome 会拒绝调用扩展。 diff --git a/src/background/index.ts b/src/background/index.ts index 430b92e..421fb44 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,4 +1,5 @@ import { handleBackgroundCommand, handleInstalled, handleStartup, handleWindowRemoved } from './service'; +import { broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage } from './service/externalBridge'; import type { BackgroundCommand } from './types'; chrome.runtime.onInstalled.addListener(() => { @@ -19,16 +20,22 @@ chrome.windows.onRemoved.addListener((windowId) => { }); chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => { - if (message.type === 'STORE_AI_PING') { + void handleExternalMessage(message).then(sendResponse).catch((error: unknown) => { sendResponse({ - success: true, - version: chrome.runtime.getManifest().version, + ok: false, + error: error instanceof Error ? error.message : String(error), }); - } + }); return true; }); +chrome.runtime.onConnectExternal.addListener(handleExternalConnect); + +chrome.storage.onChanged.addListener((changes, areaName) => { + broadcastCrawlStorageChange(changes, areaName); +}); + /** * Wrap background command handling so async errors can still be returned to callers. */ diff --git a/src/background/service/crawlTask.ts b/src/background/service/crawlTask.ts index a598d5a..88b183b 100644 --- a/src/background/service/crawlTask.ts +++ b/src/background/service/crawlTask.ts @@ -18,6 +18,11 @@ const activeCrawlControllers = new Map(); */ export async function startCrawl(platformId: string): Promise { const platform = getPlatformById(platformId); + const currentState = await getCrawlTaskState(); + + if (currentState && ['running', 'paused'].includes(currentState.status)) { + return { ok: true, data: currentState }; + } if (!platform) { return { ok: false, error: '平台配置不存在' }; diff --git a/src/background/service/externalBridge.ts b/src/background/service/externalBridge.ts new file mode 100644 index 0000000..57bb06d --- /dev/null +++ b/src/background/service/externalBridge.ts @@ -0,0 +1,207 @@ +import { platformConfigs } from '@/config/platforms'; +import type { CrawlTaskState } from '@/types'; +import { cancelCrawl, startCrawl } from './crawlTask'; +import { getCrawlTaskState } from './taskState'; + +const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; +const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL'; + +type ExternalAction = + | 'DIANSHAN_PING' + | 'DIANSHAN_START_CRAWL' + | 'DIANSHAN_GET_CRAWL_STATE' + | 'DIANSHAN_CANCEL_CRAWL' + | 'STORE_AI_PING'; + +interface ExternalMessage { + type?: ExternalAction; + action?: ExternalAction; + payload?: { + platformId?: string; + }; +} + +interface ExternalResponse { + ok: boolean; + success?: boolean; + type?: string; + data?: T; + error?: string; +} + +interface CrawlWebPayload { + state: CrawlTaskState | null; + result: Record | null; +} + +const externalPorts = new Set(); + +export async function handleExternalMessage(message: ExternalMessage): Promise { + const action = message.type ?? message.action; + + switch (action) { + case 'STORE_AI_PING': + case 'DIANSHAN_PING': + return { + ok: true, + success: true, + data: { + version: chrome.runtime.getManifest().version, + platforms: platformConfigs.map((platform) => ({ + id: platform.id, + name: platform.name, + })), + }, + }; + case 'DIANSHAN_START_CRAWL': + return startCrawlForWebsite(message.payload?.platformId); + case 'DIANSHAN_GET_CRAWL_STATE': + return { + ok: true, + data: buildCrawlWebPayload(await getCrawlTaskState()), + }; + case 'DIANSHAN_CANCEL_CRAWL': + await cancelCrawl(); + return { + ok: true, + data: buildCrawlWebPayload(null), + }; + default: + return { ok: false, error: 'unknown_external_action' }; + } +} + +export function handleExternalConnect(port: chrome.runtime.Port): void { + if (port.name !== EXTERNAL_PORT_NAME) { + port.disconnect(); + return; + } + + externalPorts.add(port); + + getCrawlTaskState() + .then((state) => { + postToExternalPort(port, { + ok: true, + type: 'DIANSHAN_CRAWL_STATE', + data: buildCrawlWebPayload(state), + }); + }) + .catch((error: unknown) => { + postToExternalPort(port, { + ok: false, + type: 'DIANSHAN_CRAWL_ERROR', + error: error instanceof Error ? error.message : String(error), + }); + }); + + port.onMessage.addListener((message: ExternalMessage) => { + void handleExternalMessage(message) + .then((response) => { + postToExternalPort(port, response); + }) + .catch((error: unknown) => { + postToExternalPort(port, { + ok: false, + type: 'DIANSHAN_CRAWL_ERROR', + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + + port.onDisconnect.addListener(() => { + externalPorts.delete(port); + }); +} + +export function broadcastCrawlStorageChange(changes: Record, areaName: string): void { + if (areaName !== 'local') { + return; + } + + const change = changes[CRAWL_TASK_STORAGE_KEY]; + + if (!change) { + return; + } + + const nextState = isCrawlTaskState(change.newValue) ? change.newValue : null; + const oldState = isCrawlTaskState(change.oldValue) ? change.oldValue : null; + const type = getBroadcastType(nextState, oldState); + + broadcastToExternalPorts({ + ok: true, + type, + data: buildCrawlWebPayload(nextState), + }); +} + +async function startCrawlForWebsite(platformId?: string): Promise> { + const response = await startCrawl(platformId ?? platformConfigs[0]?.id ?? ''); + + return { + ok: response.ok, + type: 'DIANSHAN_CRAWL_STARTED', + data: buildCrawlWebPayload(response.data ?? null), + error: response.error, + }; +} + +function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload { + return { + state, + result: state?.status === 'completed' ? collectStepResults(state) : null, + }; +} + +function collectStepResults(state: CrawlTaskState): Record { + return Object.fromEntries( + state.steps.map((step) => [ + step.uniqueKey, + { + name: step.name, + status: step.status, + result: step.result ?? null, + message: step.message ?? null, + }, + ]), + ); +} + +function getBroadcastType(nextState: CrawlTaskState | null, oldState: CrawlTaskState | null): string { + if (!nextState) { + return oldState ? 'DIANSHAN_CRAWL_CLEARED' : 'DIANSHAN_CRAWL_STATE'; + } + + if (nextState.status === 'completed') { + return 'DIANSHAN_CRAWL_DONE'; + } + + if (nextState.status === 'failed') { + return 'DIANSHAN_CRAWL_FAILED'; + } + + if (nextState.status === 'canceled') { + return 'DIANSHAN_CRAWL_CANCELED'; + } + + return 'DIANSHAN_CRAWL_STATE'; +} + +function broadcastToExternalPorts(message: ExternalResponse): void { + for (const port of externalPorts) { + postToExternalPort(port, message); + } +} + +function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse): void { + try { + port.postMessage(message); + } catch { + externalPorts.delete(port); + } +} + +function isCrawlTaskState(value: unknown): value is CrawlTaskState { + return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index eb11c05..ceb2b30 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/background/service/crawltask.ts","./src/background/service/lifecycle.ts","./src/background/service/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/background/service/crawltask.ts","./src/background/service/externalbridge.ts","./src/background/service/lifecycle.ts","./src/background/service/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file