From 4c0a1d8151b3b0ef12d616934f944bfbfa2c9bb9 Mon Sep 17 00:00:00 2001 From: zhu <1812073942@qq.com> Date: Thu, 30 Apr 2026 11:23:31 +0800 Subject: [PATCH] 1 --- src/background/domScraper.ts | 226 ++++++++++++++++++++++++++++++++++ src/background/service.ts | 227 ++++++++++++++++++++++++++++++++++- src/types/platform.ts | 8 ++ tsconfig.tsbuildinfo | 2 +- 4 files changed, 461 insertions(+), 2 deletions(-) diff --git a/src/background/domScraper.ts b/src/background/domScraper.ts index e69de29..9cb5b11 100644 --- a/src/background/domScraper.ts +++ b/src/background/domScraper.ts @@ -0,0 +1,226 @@ +import type { PlatformFieldConfig } from '@/types'; + +/** DOM 抓取后的通用结果结构。 */ +export type DomScrapeResult = Record; + +/** 默认等待时间,用于点击后或翻页后等待页面渲染。 */ +const DEFAULT_DELAY = 1500; + +/** + * 在目标网页上下文中执行 DOM 抓取。 + * + * 注意:该方法会通过 chrome.scripting.executeScript 注入到页面中执行, + * 所以依赖的辅助方法都写在函数内部,避免注入后丢失模块作用域。 + */ +export async function scrapeDomFields(fields: PlatformFieldConfig[]): Promise { + if (!document.body) { + return null; + } + + return processFields(fields, document.body); +} + + +/** 睡眠工具,给点击、翻页、异步渲染留出等待时间。 */ +const sleep = (ms?: number) => new Promise((resolve) => window.setTimeout(resolve, ms ?? DEFAULT_DELAY)); + +/** 从元素中提取实际值,默认取文本,也支持 attr、图片 src、链接 href。 */ +function extractValue(el: Element | null, config: PlatformFieldConfig): string | null { + if (!el) { + return null; + } + + if (config.attr) { + return (el.getAttribute(config.attr) || '').trim(); + } + + const tagName = el.tagName.toUpperCase(); + + if (tagName === 'IMG') { + return el.getAttribute('src'); + } + + if (tagName === 'A') { + const href = el.getAttribute('href'); + return href && !href.startsWith('http') ? window.location.origin + href : href; + } + + return (el.textContent || '').replace(/\n/g, '').trim(); +} + +/** 根据字段 condition 配置在指定 DOM 范围内自动点击目标元素。 */ +async function autoClick(config: PlatformFieldConfig, rootDom: ParentNode): Promise { + if (!config.condition) { + return; + } + + for (const selector of config.condition.list) { + const targets = Array.from(rootDom.querySelectorAll(selector)); + + for (const target of targets) { + target.click(); + await sleep(config.condition.time); + } + } +} + +/** 递归处理字段配置,支持普通字段、嵌套 row、列表和表格。 */ +async function processFields(columns: PlatformFieldConfig[], rootDom: ParentNode): Promise { + const result: DomScrapeResult = {}; + + for (const item of columns) { + await autoClick(item, rootDom); + + const element = rootDom.querySelector(item.className); + + if (!element) { + result[item.label] = '没找到该元素'; + continue; + } + + if (!item.type) { + if (item.keys && item.keys.length > 0) { + await autoClick(item, element); + result[item.label] = await processFields(item.keys, element); + } else { + await autoClick(item, element); + result[item.label] = extractValue(element, item); + } + continue; + } + + if (item.type === 1) { + result[item.label] = await processList(item, rootDom); + continue; + } + + if (item.type === 2) { + result[item.label] = await processTable(item, element); + } + } + + return result; +} + +/** 按列表配置抓取所有列表项,并按分页配置继续翻页。 */ +async function processList(config: PlatformFieldConfig, rootDom: ParentNode): Promise { + const allList: DomScrapeResult[] = []; + let pageCount = 0; + + while (true) { + pageCount += 1; + + const elements = Array.from(rootDom.querySelectorAll(config.className)); + + for (const element of elements) { + const itemData = await processFields(config.keys ?? [], element); + allList.push(itemData); + } + + if (!config.pagination) { + console.log('未配置分页信息,抓取单页后结束。'); + break; + } + + if (config.pagination.maxPage && pageCount >= config.pagination.maxPage) { + console.log('已达到配置的最大页数,停止。'); + break; + } + + const nextBtn = document.querySelector(config.pagination.nextBtn); + + if (!nextBtn) { + console.log('未找到下一页按钮,抓取结束。'); + break; + } + + const isDisabled = config.pagination.disabledClass + ? nextBtn.classList.contains(config.pagination.disabledClass) + : nextBtn.hasAttribute('disabled'); + + if (isDisabled) { + console.log('下一页按钮已禁用,抓取结束。'); + break; + } + + nextBtn.click(); + await sleep(config.pagination.delay); + } + + return allList; +} + +/** 按表格配置抓取表格行数据,并按分页配置继续翻页。 */ +async function processTable(config: PlatformFieldConfig, rootDom: ParentNode): Promise { + const allTableData: DomScrapeResult[] = []; + let pageCount = 0; + + while (true) { + pageCount += 1; + + const partsNodes: Record = {}; + + for (const part of config.tableParts ?? []) { + const partKey = part.name ?? part.label; + const partSelector = part.select ?? part.className; + const rowSelector = part.rowSelector ?? `${partSelector} tr`; + partsNodes[partKey] = Array.from(rootDom.querySelectorAll(rowSelector)); + } + + const firstPart = config.tableParts?.[0]; + const firstPartKey = firstPart ? firstPart.name ?? firstPart.label : ''; + const rowCount = partsNodes[firstPartKey]?.length || 0; + + for (let index = 0; index < rowCount; index += 1) { + const rowData: DomScrapeResult = {}; + + for (const keyItem of config.keys ?? []) { + const partKey = keyItem.part ?? firstPartKey; + const targetRowNode = partsNodes[partKey]?.[index]; + + if (!targetRowNode) { + continue; + } + + if (keyItem.keys) { + rowData[keyItem.label] = await processFields(keyItem.keys, targetRowNode); + } else { + rowData[keyItem.label] = extractValue(targetRowNode.querySelector(keyItem.className), keyItem); + } + } + + allTableData.push(rowData); + } + + if (!config.pagination) { + console.log('未配置分页信息,抓取单页后结束。'); + break; + } + + if (config.pagination.maxPage && pageCount >= config.pagination.maxPage) { + console.log('已达到配置的最大页数,停止。'); + break; + } + + const nextBtn = document.querySelector(config.pagination.nextBtn); + + if (!nextBtn) { + console.log('未找到下一页按钮,抓取结束。'); + break; + } + + const isDisabled = config.pagination.disabledClass + ? nextBtn.classList.contains(config.pagination.disabledClass) + : nextBtn.hasAttribute('disabled'); + + if (isDisabled) { + console.log('下一页按钮已禁用,抓取结束。'); + break; + } + + nextBtn.click(); + await sleep(config.pagination.delay); + } + + return allTableData; +} \ No newline at end of file diff --git a/src/background/service.ts b/src/background/service.ts index a587a7f..0199a90 100644 --- a/src/background/service.ts +++ b/src/background/service.ts @@ -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 { /** 补充 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 { } } +/** 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 */ +async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskState): Promise { + 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 { + /** 指定窗口中查询到的标签页列表。 */ + 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 { + await chrome.tabs.update(tabId, { url, active: true }); + await waitForTabLoaded(tabId); +} + +/** 等待 tab 完成页面加载。 */ +function waitForTabLoaded(tabId: number): Promise { + 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 { + 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 { + /** 轮询开始时间,用于控制最大等待时长。 */ + 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 { + /** 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 { + /** 目标页面执行 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 { + /** 当前 storage 中的任务状态。 */ + const state = await getCrawlTaskState(); + return state?.id === taskId && state.status === 'running'; +} + +/** 将指定步骤标记为运行中,同时把其它未完成步骤保持为等待。 */ +async function markStepRunning(taskId: string, stepIndex: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + /** 当前 storage 中最新的任务状态。 */ + const state = await getCrawlTaskState(); + + if (!state || state.id !== taskId || state.status === 'canceled') { + return; + } + + await setCrawlTaskState(updater(state)); +} + +/** 睡眠工具,用于轮询 DOM 等待。 */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, ms); + }); +} + /** 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。 */ async function cancelCrawl(): Promise { /** 当前保存的爬取任务状态。 */ diff --git a/src/types/platform.ts b/src/types/platform.ts index 416ba15..a9f7d13 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -33,8 +33,12 @@ export interface PlatformPaginationConfig { export interface PlatformTablePartConfig { /** 当前 table 或表格片段的名称。 */ label: string; + /** 当前 table 或表格片段的兼容名称,兼容 message.js 中的 name 写法。 */ + name?: string; /** 当前 table 或表格片段的 CSS 选择器。 */ className: string; + /** 当前 table 或表格片段的兼容选择器,兼容 message.js 中的 select 写法。 */ + select?: string; /** 行元素选择器,不填时由采集逻辑使用默认行选择器。 */ rowSelector?: string; /** 当前 table 或表格片段下需要采集的字段。 */ @@ -49,6 +53,10 @@ export interface PlatformFieldConfig { label: string; /** 字段对应的 CSS 选择器。 */ className: string; + /** 需要提取的属性名;不填时默认提取文本,图片和链接会自动取 src/href。 */ + attr?: string; + /** 表格字段所属的表格分段名称,用于横向拼接多 table 行数据。 */ + part?: string; /** 字段类型:0 普通元素(默认),1 列表,2 表格。 */ type?: PlatformFieldType; /** 进入该字段采集前需要执行的点击条件。 */ diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index e11e8aa..d8fbc61 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/main.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/shared/auth.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/main.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/shared/auth.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file