From cb5a13d3523935e9b9784cf3e3bf2dc78eefd0e4 Mon Sep 17 00:00:00 2001 From: zhu <1812073942@qq.com> Date: Wed, 13 May 2026 11:22:10 +0800 Subject: [PATCH] 1 --- s.md | 493 +++++++++++++++++++-------- src/background/index.ts | 24 +- src/content/crawlOverlay.ts | 55 +-- src/popup/hook/use-scan.ts | 13 +- src/shared/message.ts | 15 +- src/shared/request.ts | 0 tsconfig.tsbuildinfo | 2 +- we.md | 661 ------------------------------------ 8 files changed, 419 insertions(+), 844 deletions(-) create mode 100644 src/shared/request.ts delete mode 100644 we.md diff --git a/s.md b/s.md index 8febc76..fe106ef 100644 --- a/s.md +++ b/s.md @@ -1,188 +1,389 @@ -# 网站接入店闪扩展说明 +# dianshan 插件爬取任务开发文档(逻辑/通信/状态) -这个扩展已经提供网站侧调用接口。网站点击“开始”时,可以让扩展执行和 popup 手动点击“立即爬取”一样的流程:打开新浏览器窗口、进入平台后台、抓取数据。抓取完成后,扩展会通过长连接把结果推回网站。 +> 目的:把“点击爬取后发生了什么、会触发哪些方法、给谁通信、改变哪些状态”说清楚,方便后续二开与排查。 +> +> 范围:`dianshan/` 目录内的扩展(popup + background + content/overlay + 外部网页桥接)。 +> +> 注意:当前项目代码里没有 `startDianshanCrawl()` / `getDianshanCrawlState()` 这类方法名; +> 正确入口分别是 `startCrawl()`(`dianshan/src/background/task/crawlTask.ts:14`)与 `getCrawlTaskState()`(`dianshan/src/background/task/taskState.ts:10`),以及对应的消息 action(`START_CRAWL` / `GET_CRAWL_STATE`)。 -## 1. 先配置允许连接的网站域名 +--- -扩展的 `manifest.config.ts` 里有: +## 1. 关键文件索引(按职责) -```ts -externally_connectable: { - matches: [ - "http://localhost:3000/*", - ] -} -``` +### 1.1 Popup(扩展弹窗 UI) +- `dianshan/src/popup/App.vue` + - UI 展示:平台选择、开始/取消/继续按钮、步骤列表、底部语言切换/版本号 +- `dianshan/src/popup/hook/use-scan.ts` + - 与 background 通信(开始/取消/继续/读取状态) + - 监听 `chrome.storage.onChanged` 同步任务状态 + - 计时器:根据 `startedAt` 计算已用时 +- `dianshan/src/popup/hook/use-i18n.ts` + - Popup 文案 i18n(中文/英文),持久化到 `chrome.storage.local` -把你的网站域名加进去,例如: +### 1.2 Background(后台调度 + 任务状态机) +- `dianshan/src/background/index.ts` + - 统一接收 popup/content/external 的 message(`chrome.runtime.onMessage`) + - 监听窗口/Tab 关闭(自动暂停任务) + - 监听 storage 变化并对外广播(external bridge) +- `dianshan/src/background/task/crawlTask.ts` + - 任务生命周期:start/cancel/pause/resume/dismiss + - 步骤执行器:按 steps 顺序打开页面、等待、抓取、处理中断/重试 + - 完成后收尾:发送结果 -> 清空记录 -> 关闭窗口 +- `dianshan/src/background/task/taskState.ts` + - 读写 `chrome.storage.local` 的 `crawlTaskState` + - 写入时同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 推送给爬取窗口(overlay 用) +- `dianshan/src/background/task/helper.ts` + - `openSingleTabWindow()` 创建 popup 类型新窗口并返回 `{windowId, tabId}` + - `waitForTabLoaded()` 等待 tab load complete + - `scrapeStepInContent()` 与 content script 通信执行抓取(`tabs.sendMessage`) +- `dianshan/src/background/service/externalBridge.ts` + - 与外部网页(官网/业务系统)进行 external message / long-connect 通信 + - 监听 storage 变化,广播 `DIANSHAN_CRAWL_STATE / DONE / ...` 等事件 -```ts -externally_connectable: { - matches: [ - "http://localhost:3000/*", - "https://your-site.com/*", - ] -} -``` +### 1.3 Content Script(爬取窗口页内逻辑) +- `dianshan/src/content/pageRunner.ts` + - 接收 background 指令: + - `CHECK_INTERRUPT`:判断登录/验证码/404/未就绪等“需要人工处理”的中断 + - `SCRAPE_STEP`:等待关键 selector 稳定后执行 DOM 抓取 +- `dianshan/src/content/crawlOverlay.ts` + - 爬取窗口左下角悬浮 UI(圆形计时菜单) + - 点击圆形菜单展开后展示“与 popup 类似”的步骤进度/暂停提示/继续/取消 + - 接收 background 推送的 `CRAWL_STATE_UPDATE` 状态 + - 初始化时会拉取一次 `GET_CRAWL_STATE_FOR_TAB` 快照(只在爬取 tab 返回状态) -改完扩展后需要重新 `pnpm run build`,并在 Chrome 扩展管理页重新加载扩展。 +### 1.4 Shared(跨模块协议) +- `dianshan/src/shared/message.ts` + - popup/content -> background 的消息 action 类型(例如 `START_CRAWL`) +- `dianshan/src/shared/tab.ts` + - background -> content(爬取窗口 tab)消息 action 类型(例如 `CRAWL_STATE_UPDATE`) +- `dianshan/src/types/crawl.ts` + - `CrawlTaskState` / `CrawlProgressStep` / `CrawlPauseInfo` 等任务数据结构 -## 2. 网站侧需要知道扩展 ID +### 1.5 关键方法定位(按真实代码) +> 下面列的都是当前项目里“确实存在”的方法/入口,后面章节会反复引用;每条都附带文件地址+行号,方便你 Ctrl+P 直达。 -Chrome 扩展管理页打开“开发者模式”,复制这个扩展的 ID: +- popup 启动爬取:`handleScan()`:`dianshan/src/popup/hook/use-scan.ts:46` +- popup 取消爬取:`handleCancelCrawl()`:`dianshan/src/popup/hook/use-scan.ts:70` +- popup 继续爬取:`handleResumeCrawl()`:`dianshan/src/popup/hook/use-scan.ts:83` +- popup 同步状态:`handleStorageChanged()`:`dianshan/src/popup/hook/use-scan.ts:146` -```ts -const EXTENSION_ID = "这里换成你的扩展ID"; -``` +- background 消息总入口:`chrome.runtime.onMessage.addListener(...)`:`dianshan/src/background/index.ts:22` +- background 路由:`case "START_CRAWL"` 等:`dianshan/src/background/index.ts:34` +- background 窗口/Tab 关闭监听:`windows.onRemoved`/`tabs.onRemoved`:`dianshan/src/background/index.ts:92` -开发环境如果每次扩展 ID 变化,建议给扩展配置固定 key,或者每次复制新的 ID 到网站项目配置里。 +- 任务启动:`startCrawl()`:`dianshan/src/background/task/crawlTask.ts:14` +- 任务执行器:`runCrawlSteps()`:`dianshan/src/background/task/crawlTask.ts:63` +- 完成收尾:`finalizeCompletedTask()`:`dianshan/src/background/task/crawlTask.ts:312` +- 取消任务:`cancelCrawl()`:`dianshan/src/background/task/crawlTask.ts:131` +- 窗口关闭自动暂停:`pauseCrawlOnWindowRemoved()`:`dianshan/src/background/task/crawlTask.ts:158` +- Tab 关闭自动暂停:`pauseCrawlOnTabRemoved()`:`dianshan/src/background/task/crawlTask.ts:188` +- 继续/恢复:`resumeCrawl()`:`dianshan/src/background/task/crawlTask.ts:219` -## 3. 推荐的网站侧接入代码 +- 状态读写:`getCrawlTaskState()`/`setCrawlTaskState()`:`dianshan/src/background/task/taskState.ts:10` / `dianshan/src/background/task/taskState.ts:20` -网站页面加载后先建立长连接,用来接收扩展推送的进度和最终结果。 +- 新开爬取窗口:`openSingleTabWindow()`:`dianshan/src/background/task/helper.ts:8` +- 等待页面加载:`waitForTabLoaded()`:`dianshan/src/background/task/helper.ts:45` +- 让 content 抓取:`scrapeStepInContent()`:`dianshan/src/background/task/helper.ts:90` -```ts -const EXTENSION_ID = "这里换成你的扩展ID"; +- content 执行器入口:`setupPageRunner()`:`dianshan/src/content/pageRunner.ts:28` +- content 抓取处理:`handlePageRunnerMessage()`:`dianshan/src/content/pageRunner.ts:38` -type DianshanMessage = { - ok: boolean; - type?: string; - data?: { - state: any | null; - result: Record | null; - }; - error?: string; -}; +- 爬取窗口悬浮 UI:`mountCrawlOverlay()`:`dianshan/src/content/crawlOverlay.ts:36` +- 悬浮 UI 初始化拉取快照:`GET_CRAWL_STATE_FOR_TAB`:`dianshan/src/content/crawlOverlay.ts:67` +- 悬浮 UI 实时状态推送:`CRAWL_STATE_UPDATE`:`dianshan/src/content/crawlOverlay.ts:54` -let port: chrome.runtime.Port | null = null; +--- -export function connectDianshanExtension() { - port = chrome.runtime.connect(EXTENSION_ID, { name: "DIANSHAN_CRAWL" }); +## 2. 核心数据结构:CrawlTaskState(状态机) - port.onMessage.addListener((message: DianshanMessage) => { - console.log("[dianshan]", message); +存储位置:`chrome.storage.local['crawlTaskState']` - if (message.type === "DIANSHAN_CRAWL_STATE") { - // 可选:更新网站上的进度 UI - return; - } +关键字段(简化说明): +- `id`:任务唯一 ID(`platformId-startedAt`) +- `platformId/platformName`:平台信息 +- `windowId/tabId`:当前爬取窗口与承载爬取的 tab(overlay 只在这个 tab 渲染) +- `startedAt`:任务开始时间戳(用于 popup/overlay 计时) +- `status`:`running | paused | completed | failed | canceled` +- `pause`:当 `status='paused'` 时存在,包含 + - `reason`:`reauth | shield | not_found | page_not_ready | window_closed` + - `message`:给用户看的暂停提示 +- `currentStepIndex`:当前执行到的步骤索引 +- `steps[]`:每个步骤的进度条记录 + - `uniqueKey/name` + - `status`:`pending | running | success | failed` + - `result/message`:抓取结果或失败原因 - if (message.type === "DIANSHAN_CRAWL_DONE") { - // 抓取完成,最终数据在 message.data.result - console.log("抓取结果", message.data?.result); - return; - } +状态同步策略: +- background 每次 `setCrawlTaskState()` / `updateCrawlTaskState()` 都会写入 storage +- popup:监听 `chrome.storage.onChanged`,永远以 storage 为准渲染 UI +- overlay:通过 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 实时更新(并可在初始化时请求快照) +- 外部网页:`chrome.storage.onChanged` -> `externalBridge.broadcastCrawlStorageChange()` 广播结果/状态 - 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("抓取已取消"); - } - }); +## 3. “点击爬取”后的完整时序(从 UI 到抓取) - port.onDisconnect.addListener(() => { - port = null; - }); -} -``` +以下用“触发方法 / 通信对象 / 状态变化”描述每一步。 -## 4. 网站点击“开始抓取” +### 3.1 Popup 点击 “Scan now” +1) 触发方法 +- popup:`use-scan.ts -> handleScan()`(`dianshan/src/popup/hook/use-scan.ts:46`) -按钮点击时调用: +2) 通信 +- popup -> background:`sendBackgroundMessage({ action:'START_CRAWL', payload:{ platformId } })` -```ts -export async function startDianshanCrawl(platformId = "Shopee") { - const response = await chrome.runtime.sendMessage(EXTENSION_ID, { - type: "DIANSHAN_START_CRAWL", - payload: { platformId }, - }); +3) background 入口 +- `background/index.ts` 的 `chrome.runtime.onMessage` 收到 `START_CRAWL`(`dianshan/src/background/index.ts:22` / `dianshan/src/background/index.ts:34`) +- 调用:`crawlTask.startCrawl(platformId)`(`dianshan/src/background/task/crawlTask.ts:14`) - if (!response?.ok) { - throw new Error(response?.error ?? "启动抓取失败"); - } +4) 状态变化(startCrawl) +- `openSingleTabWindow(steps[0].url)`:创建爬取窗口(popup 类型)得到 `{windowId, tabId}`(`dianshan/src/background/task/helper.ts:8`) +- 构建初始 `CrawlTaskState`: + - `status='running'` + - `currentStepIndex=0` + - `steps[0].status='running'`,其余 `pending` +- `setCrawlTaskState(nextState)` 写入 storage(`dianshan/src/background/task/taskState.ts:20`) + - 这一步会触发: + - popup 的 storage 监听更新 UI + - background 同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 推送给 overlay(`dianshan/src/background/task/taskState.ts:20`) + - externalBridge 通过 storage.onChanged 对外广播(如果网页有连 port) - return response.data; -} -``` +5) 启动执行器 +- background:创建 `AbortController`,并异步执行: + - `runCrawlSteps(taskId, tabId, platform.steps, signal, startIndex=0)`(`dianshan/src/background/task/crawlTask.ts:63`) -效果等同于用户打开 popup 后手动点击“立即爬取”。如果当前已经有 running/paused 的任务,扩展会直接返回当前任务,不会重复打开多个抓取窗口。 +--- -## 5. 查询当前状态 +## 4. “执行一个步骤”会发生什么(runCrawlSteps 的循环体) -```ts -export async function getDianshanCrawlState() { - return chrome.runtime.sendMessage(EXTENSION_ID, { - type: "DIANSHAN_GET_CRAWL_STATE", - }); -} -``` +每个 step 的处理流程如下(对每个 i 从 startIndex 到 steps.length-1): -## 6. 网站侧取消抓取 +### 4.1 进入新 step:先更新状态机 +1) 触发方法 +- background:`updateCrawlTaskState(taskId, updater)` -```ts -export async function cancelDianshanCrawl() { - return chrome.runtime.sendMessage(EXTENSION_ID, { - type: "DIANSHAN_CANCEL_CRAWL", - }); -} -``` +2) 状态变化 +- `currentStepIndex = i` +- `steps[i].status = 'running'`(其它步骤保持原样) -取消后扩展会清空 `crawlTaskState`,并关闭扩展自动打开的浏览器窗口。 +3) 通信影响 +- 因为 `updateCrawlTaskState()` 内部会 `setCrawlTaskState()`: + - storage 写入 -> popup UI 更新 + - 同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` -> overlay 更新 + - storage.onChanged -> external bridge 广播(如果外部网页连着) -## 7. 返回数据结构 +### 4.2 跳转页面并等待加载完成 +1) 触发方法 +- background:`chrome.tabs.update(tabId, { url: step.url, active: true })` +- background:`waitForTabLoaded(tabId, signal)` -长连接收到 `DIANSHAN_CRAWL_DONE` 时,数据大致是: +2) 通信对象 +- background -> Chrome tabs API(无 content 参与) -```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: {} - } - } - } -} -``` +3) 状态变化 +- 不改变任务状态,只是准备让 content script 进入目标页面上下文 -网站项目里一般用 `message.data.result` 入库或展示即可;如果要展示进度,用 `message.data.state.steps`。 +### 4.3 让 content script 执行抓取(核心通信) +1) 触发方法 +- background:`scrapeStepInContent(tabId, step, signal)` -## 8. 最小使用流程 +2) 通信 +- background -> content(爬取窗口 tab):`chrome.tabs.sendMessage(tabId, { action:'SCRAPE_STEP', payload:{ fields, checkSelector } })` -```ts -connectDianshanExtension(); +3) content 入口 +- `content/pageRunner.ts` 的 `chrome.runtime.onMessage` 收到 `SCRAPE_STEP` + - `detectPageInterrupt()`:先判断是否需要人工处理(登录/验证码/404/未就绪) + - `waitForStableSelector(checkSelector, timeout)`:等待关键 DOM 稳定出现 + - `processFields(fields, document.body)`:按配置抓取 DOM 数据 -document.querySelector("#start")?.addEventListener("click", async () => { - await startDianshanCrawl("Shopee"); -}); -``` +4) 返回值约定(PageRunnerResponse) +- `ok: true, data: DomScrapeResult`:本步骤抓取成功 +- `interrupt: CrawlPauseInfo`:需要人工处理(会进入 paused) +- `ok: false, error`:未就绪/异常,background 会重试 -注意:网站必须运行在 `externally_connectable.matches` 配置过的域名下,否则 Chrome 会拒绝调用扩展。 +### 4.4 抓取成功:写入结果并标记 step success +1) 触发方法 +- background:`updateCrawlTaskState(taskId, updater)` + +2) 状态变化 +- `steps[i].status = 'success'` +- `steps[i].result = res.data` + +3) 通信影响 +- 同 4.1:storage + overlay 推送 + external bridge 广播 + +### 4.5 抓取中断:进入 paused(需要用户处理) +中断来源: +- content 检测到 `shield/reauth/not_found/page_not_ready` +- 或者:爬取窗口被用户关掉(见第 6 章) + +1) 触发方法(以 content 中断为例) +- background:`updateCrawlTaskState(taskId, s => ({ ...s, status:'paused', pause: interrupt }))`(`dianshan/src/background/task/crawlTask.ts:93`) + +2) 状态变化 +- `status='paused'` +- `pause={reason,message}` + +3) 执行器行为 +- background 在 `runCrawlSteps` 内部“死等恢复”: + - 循环检查 `getCrawlTaskState()?.status === 'paused'` + - 直到用户点击“继续”把状态改回 `running` 才继续 + +4) UI 行为 +- popup / overlay 都会显示暂停提示 + “Continue” + +--- + +## 5. “点击继续”会发生什么(RESUME_CRAWL) + +触发来源: +- popup 的 Continue +- overlay 的 Continue + +### 5.1 popup/overlay 发消息 +- popup:`use-scan.ts -> handleResumeCrawl() -> sendBackgroundMessage({action:'RESUME_CRAWL'})`(`dianshan/src/popup/hook/use-scan.ts:83`) +- overlay:`crawlOverlay.ts -> chrome.runtime.sendMessage({action:'RESUME_CRAWL'})`(`dianshan/src/content/crawlOverlay.ts:392`) + +### 5.2 background 处理 resume +入口: +- `background/index.ts` switch case `RESUME_CRAWL`(`dianshan/src/background/index.ts:66`) + - 调用 `crawlTask.resumeCrawl()`(`dianshan/src/background/task/crawlTask.ts:219`) + +resumeCrawl 分两类: + +#### A) 登录/验证码等中断(窗口仍存在) +条件: +- `state.status === 'paused'` +- `state.pause.reason !== 'window_closed'` +- `state.windowId && state.tabId` 还在 + +行为: +- 仅把状态改回 `running`(清掉 pause) +- 原来的 `runCrawlSteps()` 仍在“死等恢复”,会自动继续跑下一轮 + +#### B) 窗口被关闭导致中断(需要重开窗口) +条件: +- `state.pause.reason === 'window_closed'` 或 `windowId/tabId` 不存在 + +行为: +- 根据 `currentStepIndex` 找到“第一个未 success 的步骤”作为 `startIndex` +- `openSingleTabWindow(url)` 重新打开爬取窗口(`dianshan/src/background/task/helper.ts:8`) +- `setCrawlTaskState()` 写入新的 `windowId/tabId/status='running'/currentStepIndex=startIndex`(`dianshan/src/background/task/taskState.ts:20`) +- 新建 `AbortController`,重新启动执行器: + - `runCrawlSteps(taskId, newTabId, platform.steps, signal, startIndex)`(`dianshan/src/background/task/crawlTask.ts:63`) + +--- + +## 6. “爬取过程中窗口被关掉”会发生什么(自动暂停) + +触发点: +- `background/index.ts` 监听: + - `chrome.windows.onRemoved`(`dianshan/src/background/index.ts:92`) + - `chrome.tabs.onRemoved`(兜底)(`dianshan/src/background/index.ts:97`) + +处理逻辑: +- 调用 `crawlTask.pauseCrawlOnWindowRemoved(windowId)`(`dianshan/src/background/task/crawlTask.ts:158`)或 `pauseCrawlOnTabRemoved(tabId)`(`dianshan/src/background/task/crawlTask.ts:188`) + +行为: +1) 中止执行器 +- 找到当前任务对应的 `AbortController` 并 `abort()` +- 目的:避免后台继续对不存在的 tab 进行 `tabs.update/sendMessage` 导致刷屏错误 + +2) 状态改为 paused +- `status='paused'` +- `pause.reason='window_closed'` +- `pause.message` 提示用户点击继续可恢复 +- 清空 `windowId/tabId`(避免 UI 侧再尝试 focus 老窗口) + +用户点击继续后: +- 走第 5.2 的 B 分支:重开窗口 + 从未完成步骤继续 + +--- + +## 7. “全部步骤完成”会发生什么(发送结果 + 清理 + 关窗) + +触发点: +- `runCrawlSteps()` 完成 for 循环后: + 1) `updateCrawlTaskState(...status='completed')` + 2) 调用 `finalizeCompletedTask(taskId)` + +finalizeCompletedTask 做三件事(顺序很重要): +1) 发送结果给页面 + - background -> content(爬取 tab):`sendTabMessage(tabId,'CRAWL_COMPLETED',{ result: ... })` + - 同时 storage 完成态会触发 `externalBridge` 对外广播 `DIANSHAN_CRAWL_DONE` +2) 清空本次任务记录 + - `clearCrawlTaskState()`:移除 `chrome.storage.local['crawlTaskState']` + - popup 会收到 storage 改变并自动把 UI 恢复到“未开始”状态 +3) 关闭爬取窗口 + - `chrome.windows.remove(windowId)` + +--- + +## 8. Popup/Overlay 如何拿到状态(同步机制总结) + +### 8.1 Popup:只看 storage(强一致) +- 初次加载:发 `GET_CRAWL_STATE` +- 后续更新:监听 `chrome.storage.onChanged`,只要 `crawlTaskState` 变化就刷新 UI + +### 8.2 Overlay:推送为主,拉取为辅 +- 推送:background 写入 state 时会 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` + - overlay 监听 `chrome.runtime.onMessage`,收到 `action==='CRAWL_STATE_UPDATE'` 立刻渲染 +- 拉取:overlay 初始化时发 `GET_CRAWL_STATE_FOR_TAB` + - background 只在 `sender.tab.id === state.tabId` 时返回 state,否则返回 null + +--- + +## 9. 外部网页(官网/业务系统)如何接收结果(externalBridge) + +两种方式: + +### 9.1 onMessageExternal(短消息) +- 网页发 `DIANSHAN_START_CRAWL / DIANSHAN_GET_CRAWL_STATE / ...` +- background `externalBridge.handleExternalMessage()` 返回当前状态/平台列表等 + +### 9.2 onConnectExternal(长连接 Port) +- 网页建立 Port(name=`DIANSHAN_CRAWL`) +- background 把当前 state 发一次 `DIANSHAN_CRAWL_STATE` +- 后续只要 storage 变化,就广播: + - `DIANSHAN_CRAWL_STATE`(进行中) + - `DIANSHAN_CRAWL_DONE`(完成且带 result) + - `DIANSHAN_CRAWL_CLEARED / FAILED / CANCELED`(清理/失败/取消) + +结果结构: +- `externalBridge.buildCrawlWebPayload(state)` 会给出: + - `state`:完整状态机 + - `result`:仅当 `state.status==='completed'` 时非空,为按 step.uniqueKey 聚合的结果对象 + +--- + +## 10. 常见问题排查(建议) + +1) overlay 不显示 / 不更新 +- 先确认该 tab 是否为 `crawlTaskState.tabId` +- overlay 初始化会请求 `GET_CRAWL_STATE_FOR_TAB`,只有爬取 tab 才返回 state +- 确认 background 写入 state 时是否调用了 `setCrawlTaskState()`(它会推送 `CRAWL_STATE_UPDATE`) + +2) 点击取消 popup “闪一下” +- 通常是计时器回调解引用了空状态;popup 侧必须在 state 清空时 stop timer + +3) 窗口被关后任务还在后台跑 +- 应当由 `windows.onRemoved/tabs.onRemoved` 触发 `pauseCrawlOnWindowRemoved/TabRemoved` +- 检查 background/index.ts 是否注册了监听 + +--- + +## 11. 约定:消息 action 一览(当前) + +popup/content -> background(`dianshan/src/shared/message.ts`): +- `START_CRAWL` +- `GET_CRAWL_STATE` +- `GET_CRAWL_STATE_FOR_TAB`(仅爬取 tab 返回 state) +- `CANCEL_CRAWL` +- `RESUME_CRAWL` +- `DISMISS_CRAWL` +- `CANCEL_AUTOCLOSE`(兼容旧 overlay 按钮,不建议新逻辑依赖) + +background -> content(`dianshan/src/shared/tab.ts`): +- `CRAWL_STATE_UPDATE`(overlay/pagerunner 可用) +- `CRAWL_COMPLETED`(完成时额外发送结果) diff --git a/src/background/index.ts b/src/background/index.ts index 7eef664..c973d8a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -8,7 +8,7 @@ import { resumeCrawl, startCrawl } from "./task/crawlTask"; -import {getCrawlTaskState} from "./task/taskState"; +import {getCrawlTaskState, updateCrawlTaskState} from "./task/taskState"; chrome.runtime.onInstalled.addListener(() => { }); @@ -19,7 +19,7 @@ chrome.runtime.onStartup.addListener(() => { /** * 接受popup的指令 */ -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // 1. 统一提取 action 和 payload const action = message.action as MessageAction; const payload = message.payload; @@ -39,10 +39,30 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { resultData = await getCrawlTaskState(); break; + case "GET_CRAWL_STATE_FOR_TAB": { + // 中文备注:只允许“爬取窗口 tab”拿到状态,避免其它页面误显示悬浮面板。 + const state = await getCrawlTaskState(); + const senderTabId = sender.tab?.id; + resultData = state && senderTabId && state.tabId === senderTabId ? state : null; + break; + } + case "CANCEL_CRAWL": await cancelCrawl() break; + case "CANCEL_AUTOCLOSE": { + // 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口,这里仅做状态字段兼容。 + const state = await getCrawlTaskState(); + if (state) { + await updateCrawlTaskState(state.id, (s) => ({...s, autocloseAt: null})); + resultData = await getCrawlTaskState(); + } else { + resultData = null; + } + break; + } + case "RESUME_CRAWL": resultData = await resumeCrawl(); break; diff --git a/src/content/crawlOverlay.ts b/src/content/crawlOverlay.ts index 2152ed2..a6def3d 100644 --- a/src/content/crawlOverlay.ts +++ b/src/content/crawlOverlay.ts @@ -47,11 +47,12 @@ export function mountCrawlOverlay(): void { refs.host.style.display = 'none'; maskHost = buildMaskHost(); - // State broadcasts are targeted to the crawl tab only (background knows tabId). + // 中文备注:接收 background 通过 sendTabMessage(tabId, 'CRAWL_STATE_UPDATE', state) 推送的任务状态。 + // 旧逻辑使用 type='crawl_state_update',这里改为适配当前的 action/payload 协议。 chrome.runtime.onMessage.addListener((raw) => { - const msg = raw as { type?: string; state?: unknown } | undefined; - if (msg?.type === 'crawl_state_update') { - applyState(isCrawlTaskState(msg.state) ? (msg.state as CrawlTaskState) : null); + const msg = raw as { action?: string; payload?: unknown } | undefined; + if (msg?.action === 'CRAWL_STATE_UPDATE') { + applyState(isCrawlTaskState(msg.payload) ? (msg.payload as CrawlTaskState) : null); } return false; }); @@ -114,14 +115,14 @@ function applyState(next: CrawlTaskState | null): void { // Mask blocks page interaction while running. Paused lifts mask so user can solve captcha/login. setMaskActive(next.status === 'running'); - // Auto-collapse once per task, only while actively running. + // 中文备注:默认折叠为左下角“圆形计时菜单”,用户点击后再展开查看进度。 if (!hasExpandedOnceForThisTask && next.status === 'running') { hasExpandedOnceForThisTask = true; - setCollapsed(false); - if (autoCollapseTimer) window.clearTimeout(autoCollapseTimer); - autoCollapseTimer = window.setTimeout(() => { - if (currentState?.status === 'running') setCollapsed(true); - }, 3000); + setCollapsed(true); + if (autoCollapseTimer) { + window.clearTimeout(autoCollapseTimer); + autoCollapseTimer = null; + } } if (next.status === 'paused') { @@ -230,10 +231,16 @@ function render(state: CrawlTaskState): void { refs.autocloseBanner.style.display = 'none'; } - // Capsule text - const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null; - if (capsuleText) { - capsuleText.textContent = + // Capsule text + // 中文备注:圆形菜单上只显示计时(mm:ss)。 + const capsuleTime = refs.capsule.querySelector('.capsule-time') as HTMLSpanElement | null; + if (capsuleTime) { + capsuleTime.textContent = time; + } + + const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null; + if (capsuleText) { + capsuleText.textContent = phase === 'paused' ? '已暂停' : isTerminal(state.status) @@ -281,7 +288,8 @@ function buildDom(): OverlayRefs { host.id = OVERLAY_HOST_ID; host.style.all = 'initial'; host.style.position = 'fixed'; - host.style.right = '24px'; + // 中文备注:爬取窗口左下角放圆形菜单(计时 + 入口) + host.style.left = '24px'; host.style.bottom = '24px'; host.style.zIndex = '2147483647'; @@ -335,7 +343,7 @@ function buildDom(): OverlayRefs { `; @@ -597,10 +605,11 @@ function styleTag(): string { .cancel-btn { align-self: flex-start; padding: 5px 10px; background: transparent; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 120ms ease; } .cancel-btn:hover { color: #f85149; border-color: #f85149; } - .capsule { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px 7px 8px; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 999px; box-shadow: 0 4px 14px rgba(0,0,0,0.4); cursor: pointer; font-size: 12px; transition: all 120ms ease; } - .capsule:hover { transform: translateY(-1px); } - .radar-mini { width: 18px; height: 18px; border-radius: 50%; position: relative; overflow: hidden; background: radial-gradient(circle at center, rgba(46,160,67,0.2), rgba(46,160,67,0.02) 70%, transparent 80%); border: 1px solid rgba(46,160,67,0.4); } - .sweep-mini { position: absolute; inset: 0; background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.8) 50deg, rgba(46,160,67,0) 60deg); animation: sweep 2s linear infinite; } - .capsule-text { white-space: nowrap; } - `; -} + /* 中文备注:圆形菜单(左下角)只展示计时;点击展开查看详细进度 */ + .capsule { width: 56px; height: 56px; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 50%; box-shadow: 0 4px 14px rgba(0,0,0,0.4); cursor: pointer; font-size: 12px; transition: all 120ms ease; position: relative; } + .capsule:hover { transform: translateY(-1px); } + .radar-mini { position: absolute; inset: 6px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at center, rgba(46,160,67,0.20), rgba(46,160,67,0.03) 70%, transparent 82%); border: 1px solid rgba(46,160,67,0.40); } + .sweep-mini { position: absolute; inset: 0; background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.8) 50deg, rgba(46,160,67,0) 60deg); animation: sweep 2s linear infinite; } + .capsule-time { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: 0.2px; } + `; + } diff --git a/src/popup/hook/use-scan.ts b/src/popup/hook/use-scan.ts index bf4261e..907a5a1 100644 --- a/src/popup/hook/use-scan.ts +++ b/src/popup/hook/use-scan.ts @@ -3,8 +3,6 @@ import {platformConfigs} from '@/config/platforms'; import type {CrawlTaskState} from '@/types'; import {sendBackgroundMessage} from '@/shared/message'; -/** 用于同步爬取任务状态的 `chrome.storage.local` key。 */ -const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; /** * Popup 内的爬取状态与操作集合。 @@ -148,7 +146,7 @@ export const useScan = () => { return; } - const change = changes[CRAWL_TASK_STORAGE_KEY]; + const change = changes["crawlTaskState"]; if (!change) { return; @@ -161,19 +159,14 @@ export const useScan = () => { /** 首次加载 + 订阅 storage 事件。 */ await refreshCrawlState(); - if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { - chrome.storage.onChanged.addListener(handleStorageChanged); - } + chrome.storage.onChanged.addListener(handleStorageChanged); }); onUnmounted(() => { /** 清理计时器 + 取消订阅 storage 事件。 */ - // 中文备注:统一走 stopElapsedTimer,避免 timer 残留。 stopElapsedTimer(); - if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { - chrome.storage.onChanged.removeListener(handleStorageChanged); - } + chrome.storage.onChanged.removeListener(handleStorageChanged); }); return { diff --git a/src/shared/message.ts b/src/shared/message.ts index 1094ccc..9c855be 100644 --- a/src/shared/message.ts +++ b/src/shared/message.ts @@ -2,6 +2,13 @@ export type MessageAction = /** 获取当前爬取任务的状态*/ | 'GET_CRAWL_STATE' + /** + * 仅供 content script(爬取窗口页内悬浮面板)拉取状态: + * - 只在“当前 tab == 爬取 tab”时返回状态,其它 tab 返回 null。 + * 中文备注:用于悬浮面板初始化时拿到任务快照。 + */ + | 'GET_CRAWL_STATE_FOR_TAB' + /** 启动一个新的爬取任务 */ | 'START_CRAWL' @@ -11,6 +18,12 @@ export type MessageAction = /** 恢复之前被暂停或因中断而停止的爬取任务 */ | 'RESUME_CRAWL' + /** + * 取消自动关闭(旧悬浮面板按钮会发这个指令)。 + * 中文备注:当前版本默认完成后会清理并关闭窗口;这里保留用于兼容,避免后台报“未知指令”。 + */ + | 'CANCEL_AUTOCLOSE' + /** 忽略/关闭当前爬取任务的 UI 提示或通知(通常指任务结束后清理界面) */ | 'DISMISS_CRAWL'; @@ -38,4 +51,4 @@ export function sendBackgroundMessage(data: BackgroundMessage): Promise `startCrawl` -- `GET_CRAWL_STATE` -> `getCrawlTaskState` -- `CANCEL_CRAWL` -> `cancelCrawl` -- `RESUME_CRAWL` -> `resumeCrawl` - -这些消息类型定义在 `src/background/types.ts`。 - -## 4. 创建爬取任务和新窗口 - -文件:`src/background/service/crawlTask.ts` - -触发方法: - -- `startCrawl` -- `createCrawlWindow` -- `runCrawlSteps` - -执行过程: - -1. `startCrawl(platformId)` 先调用 `getPlatformById(platformId)`。 -2. `getPlatformById` 来自 `src/config/platforms.ts`,用于找到当前平台配置。 -3. 读取平台配置中的第一个 step: - -```ts -const firstStep = platform.steps[0]; -``` - -4. 创建初始任务状态 `CrawlTaskState`: - - `id` - - `platformId` - - `platformName` - - `startedAt` - - `status: 'running'` - - `currentStepIndex: 0` - - `steps` -5. 调用 `setCrawlTaskState(nextState)` 写入 `chrome.storage.local`。 -6. 调用 `createCrawlWindow(firstStep.url)` 打开新的普通浏览器窗口。 -7. 窗口创建成功后,把 `windowId` 写回任务状态。 -8. 调用: - -```ts -void runCrawlSteps(platform, stateWithWindow); -``` - -这里使用 `void`,表示后台任务异步继续跑;`startCrawl` 会先把初始状态返回给 popup。 - -## 5. 爬取状态保存在哪里 - -文件:`src/background/service/taskState.ts` - -核心方法: - -- `getCrawlTaskState` -- `setCrawlTaskState` -- `updateCrawlTaskState` - -保存位置: - -```ts -chrome.storage.local -``` - -保存 key: - -```ts -crawlTaskState -``` - -当前项目没有把爬取结果单独保存到数据库、文件或独立 result key。每一步的结果直接保存在: - -```ts -CrawlTaskState.steps[index].result -``` - -也就是说,popup 和 content 看到的进度、暂停信息、最终结果,都来自 `chrome.storage.local` 中的 `crawlTaskState`。 - -## 6. background 如何逐步爬取页面 - -文件:`src/background/service/crawlTask.ts` - -核心方法: - -- `runCrawlSteps` -- `getWindowActiveTabId` -- `waitForTabLoaded` -- `scrapeStepInContent` -- `sendPageRunnerMessage` -- `pauseForInterrupt` -- `waitUntilResumed` - -执行过程: - -1. `runCrawlSteps(platform, initialState)` 按 `platform.steps` 顺序循环。 -2. 每进入一个 step,会调用 `updateCrawlTaskState`: - - 更新 `currentStepIndex`。 - - 把当前 step 标记为 `running`。 - - 清空当前 step 的旧 message。 -3. 调用 `getWindowActiveTabId(windowId)` 找到爬取窗口里的当前 tab。 -4. 调用: - -```ts -chrome.tabs.update(tabId, { url: step.url, active: true }) -``` - -跳转到当前 step 配置的页面地址。 - -5. 调用 `waitForTabLoaded(tabId)` 等待 Chrome 的 tab 状态变成 `complete`。 -6. 调用 `scrapeStepInContent(tabId, step)`,让目标页面里的 content script 开始检查页面和抓取 DOM。 - -## 7. background 怎么通知目标网页抓取 - -文件:`src/background/service/crawlTask.ts` - -触发方法: - -- `scrapeStepInContent` -- `sendPageRunnerMessage` - -发送消息: - -```ts -{ - action: 'SCRAPE_STEP', - payload: { - fields: step.fields, - checkSelector: step.checkSelector, - }, -} -``` - -实际调用: - -```ts -chrome.tabs.sendMessage(tabId, message) -``` - -这里不是 popup 发给 background,而是 background 发给目标网页 tab 里的 content script。 - -`scrapeStepInContent` 还有一个容错逻辑:如果 content script 还没注入完成,出现类似 `Could not establish connection. Receiving end does not exist.` 的错误,会在 20 秒内每 500ms 重试一次。 - -## 8. content script 如何接住抓取消息 - -### 8.1 content script 挂载 - -文件:`src/content/main.ts` - -触发方法: - -- `mountApp` -- `setupPageRunner` - -执行过程: - -1. 目标页面加载时,Chrome 根据 `manifest.config.ts` 注入 `src/content/main.ts`。 -2. `main.ts` 等待 DOM 可用后执行 `mountApp()`。 -3. `mountApp()` 创建 `#dianshan-crx-root` 容器。 -4. 挂载 `src/content/App.vue`,用于显示右下角爬取计时按钮和进度面板。 -5. 调用 `setupPageRunner()` 注册页面消息监听器。 - -### 8.2 页面执行器接收 SCRAPE_STEP - -文件:`src/content/pageRunner.ts` - -触发方法: - -- `setupPageRunner` -- `handlePageRunnerMessage` -- `detectPageInterrupt` -- `waitForStableSelector` -- `processFields` - -执行过程: - -1. `setupPageRunner()` 注册: - -```ts -chrome.runtime.onMessage.addListener(...) -``` - -2. background 发送 `SCRAPE_STEP` 后,进入 `handlePageRunnerMessage()`。 -3. 先调用 `detectPageInterrupt()` 判断是否遇到: - - 登录页:`reauth` - - 验证码或风控:`shield` - - 页面不存在:`not_found` -4. 如果检测到中断,返回: - -```ts -{ ok: false, interrupt } -``` - -5. 如果没有中断,调用 `waitForStableSelector(checkSelector, 18000)`。 -6. `checkSelector` 来自 `src/config/platforms.ts` 的 step 配置,用于判断页面关键 DOM 是否出现并可见。 -7. 如果 18 秒内关键 DOM 仍未稳定出现,返回 `page_not_ready` 中断。 -8. 如果页面可用,调用: - -```ts -processFields(message.payload.fields, document.body) -``` - -开始真正的 DOM 字段采集。 - -## 9. DOM 数据怎么提取 - -文件:`src/background/domScraper.ts` - -虽然文件在 `background` 目录下,但它被 `src/content/pageRunner.ts` import 后,实际是在目标网页的 content script 环境里执行 DOM 查询。 - -核心方法: - -- `processFields` -- `processList` -- `processTable` -- `autoClick` -- `extractValue` -- `waitForElement` - -执行逻辑: - -1. `processFields(columns, rootDom)` 遍历当前 step 的 `fields`。 -2. 每个字段先执行 `autoClick(item, rootDom)`: - - 如果字段配置了 `condition.list`,就按顺序点击对应选择器。 - - 每次点击后等待 `condition.time`。 -3. 再根据 `item.className` 查询元素。 -4. 普通字段: - - 没有 `keys` 时,调用 `extractValue(element, item)`。 - - 有 `keys` 时,递归调用 `processFields(item.keys, element)`。 -5. 列表字段: - - `type === 1` 时进入 `processList`。 - - 按 `config.className` 找到列表项。 - - 对每个列表项递归执行 `processFields(config.keys, element)`。 - - 如果配置了 `pagination`,会点击下一页继续采集。 -6. 表格字段: - - `type === 2` 时进入 `processTable`。 - - 按 `tableParts` 找到不同 table 片段。 - - 以第一个 part 的行数为准,按行拼接不同 part 的字段。 - - 如果配置了 `pagination`,会点击下一页继续采集。 -7. `extractValue` 的取值规则: - - 配置了 `attr` 就取指定属性。 - - `IMG` 默认取 `src`。 - - `A` 默认取 `href`,相对路径会拼上当前 origin。 - - 其他元素默认取 `textContent`。 - -## 10. 采集结果怎么回传和保存 - -数据流向: - -1. `processFields` 返回当前 step 的 DOM 采集结果。 -2. `src/content/pageRunner.ts` 返回: - -```ts -{ ok: true, data } -``` - -3. `src/background/service/crawlTask.ts` 的 `scrapeStepInContent` 接到 response。 -4. `runCrawlSteps` 调用 `updateCrawlTaskState`: - -```ts -steps[index].result = response.data -steps[index].status = 'success' -``` - -5. 最新任务状态写回 `chrome.storage.local` 的 `crawlTaskState`。 -6. popup 和 content 浮窗每秒发送 `GET_CRAWL_STATE`,读取到最新结果并展示。 - -所以当前项目的数据传递是: - -```text -目标网页 DOM - -> content/pageRunner.ts - -> background/crawlTask.ts - -> chrome.storage.local:crawlTaskState - -> popup/App.vue 和 content/App.vue 轮询展示 -``` - -## 11. popup 进度如何刷新 - -文件:`src/popup/App.vue` - -触发方法: - -- `refreshCrawlState` -- `sendBackgroundMessage` -- `updateElapsedSeconds` - -执行过程: - -1. popup 打开后每秒调用一次 `refreshCrawlState()`。 -2. `refreshCrawlState()` 发送: - -```ts -{ action: 'GET_CRAWL_STATE' } -``` - -3. background 的 `handleBackgroundCommand` 收到后调用 `getCrawlTaskState()`。 -4. popup 拿到 `CrawlTaskState` 后: - - 展示平台名。 - - 展示运行时间。 - - 展示每个 step 的状态。 - - 如果 step 有 `result`,用 `JSON.stringify(step.result, null, 2)` 打印出来。 - -## 12. 目标网页右下角按钮如何刷新 - -文件:`src/content/App.vue` - -触发方法: - -- `onMounted` -- `refreshCrawlState` -- `updateElapsedSeconds` -- `handleResumeCrawl` - -执行过程: - -1. content script 挂载后,`src/content/App.vue` 每秒调用 `refreshCrawlState()`。 -2. 它同样发送: - -```ts -{ action: 'GET_CRAWL_STATE' } -``` - -3. 如果任务状态是 `running` 或 `paused`,显示右下角计时按钮。 -4. 点击按钮会展开进度面板。 -5. 如果任务是 `paused`,面板里显示暂停原因,并提供“我已处理,继续”按钮。 -6. 点击继续时,调用 `handleResumeCrawl()`,发送: - -```ts -{ action: 'RESUME_CRAWL' } -``` - -## 13. 暂停和继续流程 - -触发原因主要来自 `src/content/pageRunner.ts`: - -- `isLoginPage()` 检测到登录页。 -- `isShieldPage()` 检测到验证码或风控。 -- `isNotFoundPage()` 检测到页面不存在。 -- `waitForStableSelector()` 超时,认为页面关键内容没准备好。 - -流程: - -1. content 返回: - -```ts -{ ok: false, interrupt } -``` - -2. background 的 `runCrawlSteps` 检测到 `response.interrupt`。 -3. 调用 `pauseForInterrupt(taskId, stepIndex, interrupt)`。 -4. `pauseForInterrupt` 把状态写成: - - `status: 'paused'` - - `pause: interrupt` - - 当前 step 保持 `running` - - 当前 step 的 `message` 设置为中断提示 -5. popup 和 content 每秒轮询到 paused 状态后展示提示。 -6. 用户处理完登录或验证码后,点击继续。 -7. popup 或 content 发送 `RESUME_CRAWL`。 -8. background 调用 `resumeCrawl()`: - - `status` 改回 `running` - - 清空 `pause` - - 当前 step 的 `message` 清空 -9. `runCrawlSteps` 中的 `waitUntilResumed()` 发现状态恢复为 `running`,重新执行当前 step。 - -## 14. 取消和关闭窗口流程 - -### 14.1 用户点击取消 - -文件: - -- `src/popup/App.vue` -- `src/background/service/lifecycle.ts` -- `src/background/service/crawlTask.ts` - -方法: - -- `handleCancelCrawl` -- `handleBackgroundCommand` -- `cancelCrawl` - -流程: - -1. popup 发送: - -```ts -{ action: 'CANCEL_CRAWL' } -``` - -2. background 分发到 `cancelCrawl()`。 -3. `cancelCrawl()` 把当前任务状态改成 `canceled`。 -4. 当前 step 被标记为 `failed`,message 为 `用户已取消`。 -5. 如果存在 `windowId`,调用 `chrome.windows.remove(windowId)` 关闭爬取窗口。 - -### 14.2 用户直接关闭爬取窗口 - -文件: - -- `src/background/index.ts` -- `src/background/service/lifecycle.ts` -- `src/background/service/crawlTask.ts` - -方法: - -- `chrome.windows.onRemoved.addListener` -- `handleWindowRemoved` -- `cancelCrawlWhenWindowRemoved` - -流程: - -1. Chrome 触发 `windows.onRemoved`。 -2. `handleWindowRemoved(windowId)` 被调用。 -3. `cancelCrawlWhenWindowRemoved(windowId)` 检查关闭的是否是当前爬取窗口。 -4. 如果匹配且任务还在 running,就把任务改成 `canceled`。 -5. 当前 step 标记为 `failed`,message 为 `爬取窗口已关闭`。 - -## 15. 平台配置如何驱动爬取 - -文件:`src/config/platforms.ts` - -核心导出: - -- `PLATFORM_CONFIGS` -- `getPlatformById` - -平台配置结构来自 `src/types/platform.ts`: - -- `PlatformConfig` -- `PlatformStepConfig` -- `PlatformFieldConfig` -- `PlatformPaginationConfig` -- `PlatformTablePartConfig` -- `PlatformClickCondition` - -关键字段: - -- `steps`:平台要按顺序爬取的页面。 -- `step.url`:当前步骤要打开的页面地址。 -- `step.checkSelector`:判断页面是否可抓取的关键元素。 -- `step.fields`:当前页面要采集的字段。 -- `field.className`:字段选择器。 -- `field.keys`:子字段,支持递归。 -- `field.type`:字段类型,默认普通字段,`1` 是列表,`2` 是表格。 -- `field.condition`:采集字段前要自动点击的元素。 -- `field.pagination`:列表或表格翻页配置。 -- `field.tableParts`:表格分段配置,用来拼接多个 table 片段。 - -## 16. 类型结构在哪里看 - -主要类型文件: - -- `src/background/types.ts`:background 接收的消息类型和统一响应类型。 -- `src/types/crawl.ts`:爬取任务状态、步骤状态、暂停原因。 -- `src/types/platform.ts`:平台配置、字段配置、分页配置、表格配置。 -- `src/types/index.ts`:统一导出类型。 - -最关键的运行时状态是 `CrawlTaskState`: - -```ts -interface CrawlTaskState { - id: string; - platformId: string; - platformName: string; - windowId?: number; - startedAt: number; - status: 'running' | 'paused' | 'completed' | 'failed' | 'canceled'; - pause?: CrawlPauseInfo; - currentStepIndex: number; - steps: CrawlProgressStep[]; -} -``` - -每个 step 的结果保存在: - -```ts -interface CrawlProgressStep { - name: string; - uniqueKey: string; - status: 'pending' | 'running' | 'success' | 'failed'; - message?: string; - result?: unknown; -} -``` - -## 17. 整体数据流图 - -```text -用户点击插件图标 - -> src/popup/App.vue:onMounted - -> 读取 token 和 crawlTaskState - -用户点击立即爬取 - -> src/popup/App.vue:handleScan - -> chrome.runtime.sendMessage({ action: 'START_CRAWL' }) - -background 接收消息 - -> src/background/index.ts:handleBackgroundMessage - -> src/background/service/lifecycle.ts:handleBackgroundCommand - -> src/background/service/crawlTask.ts:startCrawl - -创建任务和窗口 - -> src/background/service/taskState.ts:setCrawlTaskState - -> src/background/service/crawlTask.ts:createCrawlWindow - -> src/background/service/crawlTask.ts:runCrawlSteps - -逐个页面跳转 - -> chrome.tabs.update(tabId, { url: step.url }) - -> src/background/service/crawlTask.ts:waitForTabLoaded - -> src/background/service/crawlTask.ts:scrapeStepInContent - -目标页面执行抓取 - -> chrome.tabs.sendMessage({ action: 'SCRAPE_STEP' }) - -> src/content/pageRunner.ts:handlePageRunnerMessage - -> src/content/pageRunner.ts:waitForStableSelector - -> src/background/domScraper.ts:processFields - -结果回到 background - -> src/background/service/crawlTask.ts:runCrawlSteps - -> src/background/service/taskState.ts:updateCrawlTaskState - -> 写入 chrome.storage.local:crawlTaskState - -多端展示 - -> src/popup/App.vue 每秒 GET_CRAWL_STATE - -> src/content/App.vue 每秒 GET_CRAWL_STATE - -> popup 展示完整进度和结果 - -> 目标网页右下角展示计时按钮和进度面板 -``` - -## 18. 推荐阅读代码顺序 - -如果要按最顺的方式读代码,可以这样看: - -1. `manifest.config.ts`:先看扩展入口。 -2. `src/popup/App.vue`:看用户点按钮时发什么消息。 -3. `src/background/index.ts`:看 background 怎么接消息。 -4. `src/background/service/lifecycle.ts`:看消息怎么分发。 -5. `src/background/service/crawlTask.ts`:看任务创建、窗口打开、页面跳转、暂停恢复。 -6. `src/background/service/taskState.ts`:看状态怎么保存。 -7. `src/content/main.ts`:看 content script 怎么挂载。 -8. `src/content/pageRunner.ts`:看目标页面怎么接收 background 抓取指令。 -9. `src/background/domScraper.ts`:看 DOM 字段怎么递归提取。 -10. `src/config/platforms.ts`:结合抓取逻辑看配置如何控制页面和字段。 -11. `src/types/crawl.ts`、`src/types/platform.ts`:最后看数据结构。