This commit is contained in:
zhu
2026-04-30 11:23:31 +08:00
parent 7ca9dabaf9
commit 4c0a1d8151
4 changed files with 461 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
import { getPlatformById } from '@/config/platforms';
import type { CrawlProgressStep, CrawlTaskState } from '@/types';
import type { CrawlProgressStep, CrawlTaskState, PlatformConfig, PlatformStepConfig } from '@/types';
import { scrapeDomFields, type DomScrapeResult } from './domScraper';
import type { BackgroundCommand, BackgroundResponse, CrawlStateResponse } from './types';
/** chrome.storage.local 中保存当前爬取任务状态的键名。 */
@@ -83,6 +84,7 @@ async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
/** 补充 windowId 后的任务状态,后续可用于取消或监听窗口关闭。 */
const stateWithWindow = { ...nextState, windowId: windowInfo.id };
await setCrawlTaskState(stateWithWindow);
void runCrawlSteps(platform, stateWithWindow);
return { ok: true, data: stateWithWindow };
} catch (error: unknown) {
/** 窗口创建失败时写入的失败状态,供 popup/content 显示错误进度。 */
@@ -98,6 +100,229 @@ async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
}
}
/** 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 */
async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskState): Promise<void> {
if (!initialState.windowId) {
return;
}
try {
/** 新窗口中的目标标签页 ID后续所有跳转和脚本注入都依赖它。 */
const tabId = await getWindowActiveTabId(initialState.windowId);
for (let stepIndex = 0; stepIndex < platform.steps.length; stepIndex += 1) {
/** 当前正在执行的平台页面步骤配置。 */
const step = platform.steps[stepIndex];
if (!(await isTaskRunning(initialState.id))) {
return;
}
await markStepRunning(initialState.id, stepIndex);
await openStepPage(tabId, step.url);
/** 当前页面核心 DOM 是否已经出现。 */
const isReady = await waitForStepReady(tabId, step);
if (!isReady) {
await markStepFailed(initialState.id, stepIndex, '页面关键 DOM 未加载完成');
await markTaskFailed(initialState.id);
return;
}
/** 注入页面执行后的字段抓取结果。 */
const data = await scrapeStepFields(tabId, step);
console.log(`[crawl] ${platform.name} - ${step.name} 提取成功`, data);
await markStepSuccess(initialState.id, stepIndex);
}
await markTaskCompleted(initialState.id);
} catch (error: unknown) {
console.error('[crawl] 执行失败', error);
await markTaskFailed(initialState.id, error instanceof Error ? error.message : '爬取执行失败');
}
}
/** 获取指定窗口中的活动 tab ID。 */
async function getWindowActiveTabId(windowId: number): Promise<number> {
/** 指定窗口中查询到的标签页列表。 */
const tabs = await chrome.tabs.query({ windowId, active: true });
/** 当前窗口里用于承载爬取页面的活动标签页。 */
const tab = tabs[0];
if (!tab?.id) {
throw new Error('未找到爬取窗口中的标签页');
}
return tab.id;
}
/** 打开某个 steps 页面,并等待浏览器报告 tab 加载完成。 */
async function openStepPage(tabId: number, url: string): Promise<void> {
await chrome.tabs.update(tabId, { url, active: true });
await waitForTabLoaded(tabId);
}
/** 等待 tab 完成页面加载。 */
function waitForTabLoaded(tabId: number): Promise<void> {
return new Promise((resolve) => {
/** 页面加载兜底定时器,避免某些站点不触发 complete 时流程永久挂起。 */
const timeout = globalThis.setTimeout(() => {
chrome.tabs.onUpdated.removeListener(handleUpdated);
resolve();
}, 15000);
/** chrome.tabs.onUpdated 的监听器,用于捕获指定 tab 的 complete 状态。 */
function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) {
if (updatedTabId === tabId && changeInfo.status === 'complete') {
globalThis.clearTimeout(timeout);
chrome.tabs.onUpdated.removeListener(handleUpdated);
resolve();
}
}
chrome.tabs.onUpdated.addListener(handleUpdated);
});
}
/** 等待步骤配置中的 checkSelector 出现;第一次超时后刷新页面再重试一次。 */
async function waitForStepReady(tabId: number, step: PlatformStepConfig): Promise<boolean> {
if (await waitForSelector(tabId, step.checkSelector, 5000)) {
return true;
}
await chrome.tabs.reload(tabId);
await waitForTabLoaded(tabId);
return waitForSelector(tabId, step.checkSelector, 5000);
}
/** 在目标页面轮询检查指定 selector 是否存在。 */
async function waitForSelector(tabId: number, selector: string, timeoutMs: number): Promise<boolean> {
/** 轮询开始时间,用于控制最大等待时长。 */
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
/** 当前页面是否已经能查询到目标元素。 */
const exists = await checkSelectorExists(tabId, selector);
if (exists) {
return true;
}
await sleep(500);
}
return false;
}
/** 注入轻量脚本检查页面里是否存在指定 selector。 */
async function checkSelectorExists(tabId: number, selector: string): Promise<boolean> {
/** chrome.scripting.executeScript 返回的注入执行结果。 */
const results = await chrome.scripting.executeScript({
target: { tabId },
func: (targetSelector: string) => Boolean(document.querySelector(targetSelector)),
args: [selector],
});
return Boolean(results[0]?.result);
}
/** 注入 domScraper 到目标页面,并根据当前 step.fields 提取页面数据。 */
async function scrapeStepFields(tabId: number, step: PlatformStepConfig): Promise<DomScrapeResult | null> {
/** 目标页面执行 DOM 抓取后返回的结果数组。 */
const results = await chrome.scripting.executeScript({
target: { tabId },
func: scrapeDomFields,
args: [step.fields],
});
return results[0]?.result ?? null;
}
/** 判断指定任务是否仍处于 running 状态。 */
async function isTaskRunning(taskId: string): Promise<boolean> {
/** 当前 storage 中的任务状态。 */
const state = await getCrawlTaskState();
return state?.id === taskId && state.status === 'running';
}
/** 将指定步骤标记为运行中,同时把其它未完成步骤保持为等待。 */
async function markStepRunning(taskId: string, stepIndex: number): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
currentStepIndex: stepIndex,
status: 'running',
steps: state.steps.map((step, index) => ({
...step,
status: index === stepIndex ? 'running' : step.status,
message: index === stepIndex ? undefined : step.message,
})),
}));
}
/** 将指定步骤标记为成功。 */
async function markStepSuccess(taskId: string, stepIndex: number): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
steps: state.steps.map((step, index) =>
index === stepIndex ? { ...step, status: 'success', message: undefined } : step,
),
}));
}
/** 将指定步骤标记为失败,并记录失败原因。 */
async function markStepFailed(taskId: string, stepIndex: number, message: string): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
currentStepIndex: stepIndex,
steps: state.steps.map((step, index) =>
index === stepIndex ? { ...step, status: 'failed', message } : step,
),
}));
}
/** 将整个任务标记为完成。 */
async function markTaskCompleted(taskId: string): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
status: 'completed',
steps: state.steps.map((step) => (step.status === 'running' ? { ...step, status: 'success' } : step)),
}));
}
/** 将整个任务标记为失败。 */
async function markTaskFailed(taskId: string, message = '爬取失败'): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
status: 'failed',
steps: state.steps.map((step, index) =>
index === state.currentStepIndex && step.status === 'running' ? { ...step, status: 'failed', message } : step,
),
}));
}
/** 读取任务状态后执行不可变更新,避免覆盖已取消或已替换的任务。 */
async function updateCrawlTaskState(
taskId: string,
updater: (state: CrawlTaskState) => CrawlTaskState,
): Promise<void> {
/** 当前 storage 中最新的任务状态。 */
const state = await getCrawlTaskState();
if (!state || state.id !== taskId || state.status === 'canceled') {
return;
}
await setCrawlTaskState(updater(state));
}
/** 睡眠工具,用于轮询 DOM 等待。 */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, ms);
});
}
/** 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。 */
async function cancelCrawl(): Promise<CrawlStateResponse> {
/** 当前保存的爬取任务状态。 */