1
This commit is contained in:
493
s.md
493
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<string, unknown> | 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`(完成时额外发送结果)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
<button class="capsule" type="button" aria-label="展开面板">
|
||||
<div class="radar-mini"><div class="sweep-mini"></div></div>
|
||||
<span class="capsule-text">爬取中</span>
|
||||
<span class="capsule-time">爬取中</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
@@ -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; }
|
||||
</style>`;
|
||||
}
|
||||
/* 中文备注:圆形菜单(左下角)只展示计时;点击展开查看详细进度 */
|
||||
.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; }
|
||||
</style>`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
0
src/shared/request.ts
Normal file
0
src/shared/request.ts
Normal file
@@ -1 +1 @@
|
||||
{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/externalbridge.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./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-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"}
|
||||
{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/externalbridge.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./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-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/request.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"}
|
||||
661
we.md
661
we.md
@@ -1,661 +0,0 @@
|
||||
# 店闪扩展执行链路说明
|
||||
|
||||
这份文档按当前项目代码整理,目的是方便顺着代码阅读整个爬取流程。
|
||||
|
||||
## 1. 扩展入口关系
|
||||
|
||||
扩展入口由 `manifest.config.ts` 配置:
|
||||
|
||||
- `action.default_popup` 指向 `src/popup/index.html`,点击浏览器插件图标后打开 popup。
|
||||
- `background.service_worker` 指向 `src/background/index.ts`,负责接收消息、创建爬取窗口、执行爬取任务。
|
||||
- `content_scripts` 指向 `src/content/main.ts`,会注入到所有 `http/https` 页面,用于右下角进度浮窗和页面 DOM 抓取。
|
||||
- `permissions` 中的 `storage`、`tabs`、`windows` 支撑状态保存、页面跳转和窗口管理。
|
||||
|
||||
## 2. 点击 popup 后发生什么
|
||||
|
||||
### 2.1 popup 初始化
|
||||
|
||||
文件:`src/popup/App.vue`
|
||||
|
||||
触发方法:
|
||||
|
||||
- `onMounted`
|
||||
- `getToken`
|
||||
- `refreshCrawlState`
|
||||
- `updateElapsedSeconds`
|
||||
|
||||
执行过程:
|
||||
|
||||
1. 用户点击扩展图标,Chrome 打开 `src/popup/index.html`。
|
||||
2. Vue 加载 `src/popup/App.vue`。
|
||||
3. `onMounted` 执行:
|
||||
- 调用 `getToken()` 读取登录状态。
|
||||
- 调用 `refreshCrawlState()` 获取当前爬取任务。
|
||||
- 启动 `setInterval`,每秒刷新爬取状态和计时。
|
||||
4. `getToken()` 来自 `src/shared/auth.ts`:
|
||||
- 优先从 `chrome.storage.local` 读取 `token`。
|
||||
- 非扩展环境下从 `localStorage` 读取。
|
||||
|
||||
### 2.2 点击“立即爬取”
|
||||
|
||||
文件:`src/popup/App.vue`
|
||||
|
||||
触发方法:
|
||||
|
||||
- `handleScan`
|
||||
- `sendBackgroundMessage`
|
||||
|
||||
执行过程:
|
||||
|
||||
1. 用户点击“立即爬取”按钮。
|
||||
2. `handleScan()` 检查当前是否选择了平台。
|
||||
3. 通过 `sendBackgroundMessage()` 发送消息给 background:
|
||||
|
||||
```ts
|
||||
{
|
||||
action: 'START_CRAWL',
|
||||
payload: { platformId: selectedPlatform.value.id },
|
||||
}
|
||||
```
|
||||
|
||||
4. `sendBackgroundMessage()` 实际调用:
|
||||
|
||||
```ts
|
||||
chrome.runtime.sendMessage(message)
|
||||
```
|
||||
|
||||
5. popup 收到 background 返回的 `CrawlTaskState` 后,更新本地的 `crawlState`,页面开始显示进度。
|
||||
|
||||
## 3. background 如何接住 popup 消息
|
||||
|
||||
### 3.1 background 消息入口
|
||||
|
||||
文件:`src/background/index.ts`
|
||||
|
||||
触发方法:
|
||||
|
||||
- `chrome.runtime.onMessage.addListener`
|
||||
- `handleBackgroundMessage`
|
||||
|
||||
执行过程:
|
||||
|
||||
1. background service worker 启动后,注册 `chrome.runtime.onMessage`。
|
||||
2. popup 发来的 `START_CRAWL` 会进入 `handleBackgroundMessage()`。
|
||||
3. `handleBackgroundMessage()` 调用:
|
||||
|
||||
```ts
|
||||
handleBackgroundCommand(message)
|
||||
```
|
||||
|
||||
4. 如果执行成功,调用 `sendResponse(result)` 把结果回给 popup。
|
||||
5. 如果执行失败,统一返回:
|
||||
|
||||
```ts
|
||||
{ ok: false, error: messageText }
|
||||
```
|
||||
|
||||
### 3.2 background 指令分发
|
||||
|
||||
文件:`src/background/service.ts`
|
||||
|
||||
作用:
|
||||
|
||||
```ts
|
||||
export {
|
||||
handleBackgroundCommand,
|
||||
handleInstalled,
|
||||
handleStartup,
|
||||
handleWindowRemoved,
|
||||
} from './service/lifecycle';
|
||||
```
|
||||
|
||||
这个文件现在只是 re-export,真正逻辑在 `src/background/service/lifecycle.ts`。
|
||||
|
||||
文件:`src/background/service/lifecycle.ts`
|
||||
|
||||
触发方法:
|
||||
|
||||
- `handleBackgroundCommand`
|
||||
|
||||
分发关系:
|
||||
|
||||
- `START_CRAWL` -> `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`:最后看数据结构。
|
||||
Reference in New Issue
Block a user