@@ -1,26 +1,32 @@
import { getPlatformById } from '@/config/platforms' ;
import { getPlatformById } from '@/config/platforms' ;
import type { CrawlProgressStep , CrawlTaskState , PlatformConfig , PlatformStepConfig } from '@/types' ;
import { scrapeDomFields , type DomScrapeResult } from './domScraper' ;
import type { BackgroundCommand , BackgroundResponse , CrawlStateResponse } from './types' ;
/** chrome.storage.local 中保存当前爬取任务状态的键名。 */
// chrome.storage.local 中保存当前爬取任务状态的键名。
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState' ;
/** 扩展安装完成时的初始化入口,当前仅保留日志方便调试生命周期。 */
/**
* 扩展安装完成时的初始化入口,当前仅保留日志方便调试生命周期。
*/
export async function handleInstalled ( ) : Promise < void > {
console . log ( '[background] installed' ) ;
}
/** 浏览器启动并加载扩展时的初始化入口,当前仅保留日志方便调试生命周期。 */
/**
* 浏览器启动并加载扩展时的初始化入口,当前仅保留日志方便调试生命周期。
*/
export async function handleStartup ( ) : Promise < void > {
console . log ( '[background] startup' ) ;
}
/** 监听窗口关闭事件;如果关闭的是爬取窗口,就把当前任务标记为取消。 */
/**
* 监听窗口关闭事件;如果关闭的是爬取窗口,就把当前任务标记为取消。
*/
export async function handleWindowRemoved ( windowId : number ) : Promise < void > {
console . log ( '[background] window removed' , windowId ) ;
/** 当前保存的爬取任务状态。 */
// 当前保存的爬取任务状态。
const state = await getCrawlTaskState ( ) ;
if ( state ? . windowId === windowId && state . status === 'running' ) {
@@ -34,7 +40,9 @@ export async function handleWindowRemoved(windowId: number): Promise<void> {
}
}
/** 根据 popup/content 发来的 action 分发到对应的后台处理函数。 */
/**
* 根据 popup/content 发来的 action 分发到对应的后台处理函数。
*/
export async function handleBackgroundCommand (
message : BackgroundCommand ,
) : Promise < BackgroundResponse | CrawlStateResponse > {
@@ -50,9 +58,11 @@ export async function handleBackgroundCommand(
}
}
/** 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。 */
/**
* 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。
*/
async function startCrawl ( platformId : string ) : Promise < CrawlStateResponse > {
/** 根据平台 ID 找到对应的平台爬取配置。 */
// 根据平台 ID 找到对应的平台爬取配置。
const platform = getPlatformById ( platformId ) ;
if ( ! platform ) {
@@ -65,9 +75,10 @@ async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
return { ok : false , error : '平台未配置爬取步骤' } ;
}
/** 当前任务的开始时间戳,用于计算正计时。 */
// 当前任务的开始时间戳,用于计算正计时。
const startedAt = Date . now ( ) ;
/** 窗口创建前的初始任务状态,先写入 storage 让所有页面能立即感知爬取开始。 */
// 窗口创建前的初始任务状态。
// 先写入 storage 让所有页面能立即感知爬取开始。
const nextState : CrawlTaskState = {
id : ` ${ platform . id } - ${ startedAt } ` ,
platformId : platform.id ,
@@ -85,15 +96,17 @@ async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
await setCrawlTaskState ( nextState ) ;
try {
/** background 创建出来的目标平台窗口信息。 */
// background 创建出来的目标平台窗口信息。
const windowInfo = await createCrawlWindow ( firstStep . url ) ;
/** 补充 windowId 后的任务状态,后续可用于取消或监听窗口关闭。 */
// 补充 windowId 后的任务状态。
// 后续可用于取消或监听窗口关闭。
const stateWithWindow = { . . . nextState , windowId : windowInfo.id } ;
await setCrawlTaskState ( stateWithWindow ) ;
void runCrawlSteps ( platform , stateWithWindow ) ;
return { ok : true , data : stateWithWindow } ;
} catch ( error : unknown ) {
/** 窗口创建失败时写入的失败状态,供 popup/content 显示错误进度。 */
// 窗口创建失败时写入的失败状态。
// 供 popup/content 显示错误进度。
const failedState : CrawlTaskState = {
. . . nextState ,
status : 'failed' ,
@@ -106,18 +119,21 @@ async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
}
}
/** 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 */
/**
* 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。
*/
async function runCrawlSteps ( platform : PlatformConfig , initialState : CrawlTaskState ) : Promise < void > {
if ( ! initialState . windowId ) {
return ;
}
try {
/** 新窗口中的目标标签页 ID,后续所有跳转和脚本注入都依赖它。 */
// 新窗口中的目标标签页 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 ) ) ) {
@@ -127,7 +143,7 @@ async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskSt
await markStepRunning ( initialState . id , stepIndex ) ;
await openStepPage ( tabId , step . url ) ;
/** 当前页面核心 DOM 是否已经出现。 */
// 当前页面核心 DOM 是否已经出现。
const isReady = await waitForStepReady ( tabId , step ) ;
if ( ! isReady ) {
@@ -136,7 +152,7 @@ async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskSt
return ;
}
/** 注入页面执行后的字段抓取结果。 */
// 注入页面执行后的字段抓取结果。
const data = await scrapeStepFields ( tabId , step ) ;
console . log ( ` [crawl] ${ platform . name } - ${ step . name } 提取成功 ` , data ) ;
await markStepSuccess ( initialState . id , stepIndex ) ;
@@ -149,11 +165,13 @@ async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskSt
}
}
/** 获取指定窗口中的活动 tab ID。 */
/**
* 获取指定窗口中的活动 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 ) {
@@ -163,22 +181,28 @@ async function getWindowActiveTabId(windowId: number): Promise<number> {
return tab . id ;
}
/** 打开某个 steps 页面,并等待浏览器报告 tab 加载完成。 */
/**
* 打开某个 steps 页面,并等待浏览器报告 tab 加载完成。
*/
async function openStepPage ( tabId : number , url : string ) : Promise < void > {
await chrome . tabs . update ( tabId , { url , active : true } ) ;
await waitForTabLoaded ( tabId ) ;
}
/** 等待 tab 完成页面加载。 */
/**
* 等待 tab 完成页面加载。
*/
function waitForTabLoaded ( tabId : number ) : Promise < void > {
return new Promise ( ( resolve ) = > {
/** 页面加载兜底定时器,避免某些站点不触发 complete 时流程永久挂起。 */
// 页面加载兜底定时器。
// 避免某些站点不触发 complete 时流程永久挂起。
const timeout = globalThis . setTimeout ( ( ) = > {
chrome . tabs . onUpdated . removeListener ( handleUpdated ) ;
resolve ( ) ;
} , 15000 ) ;
/** chrome.tabs.onUpdated 的监听器,用于捕获指定 tab 的 complete 状态。 */
// chrome.tabs.onUpdated 的监听器。
// 用于捕获指定 tab 的 complete 状态。
function handleUpdated ( updatedTabId : number , changeInfo : { status? : string } ) {
if ( updatedTabId === tabId && changeInfo . status === 'complete' ) {
globalThis . clearTimeout ( timeout ) ;
@@ -191,7 +215,9 @@ function waitForTabLoaded(tabId: number): Promise<void> {
} ) ;
}
/** 等待步骤配置中的 checkSelector 出现;第一次超时后刷新页面再重试一次。 */
/**
* 等待步骤配置中的 checkSelector 出现;第一次超时后刷新页面再重试一次。
*/
async function waitForStepReady ( tabId : number , step : PlatformStepConfig ) : Promise < boolean > {
if ( await waitForSelector ( tabId , step . checkSelector , 5000 ) ) {
return true ;
@@ -202,13 +228,15 @@ async function waitForStepReady(tabId: number, step: PlatformStepConfig): Promis
return waitForSelector ( tabId , step . checkSelector , 5000 ) ;
}
/** 在目标页面轮询检查指定 selector 是否存在。 */
/**
* 在目标页面轮询检查指定 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 ) {
@@ -221,9 +249,11 @@ async function waitForSelector(tabId: number, selector: string, timeoutMs: numbe
return false ;
}
/** 注入轻量脚本检查页面里是否存在指定 selector。 */
/**
* 注入轻量脚本检查页面里是否存在指定 selector。
*/
async function checkSelectorExists ( tabId : number , selector : string ) : Promise < boolean > {
/** chrome.scripting.executeScript 返回的注入执行结果。 */
// chrome.scripting.executeScript 返回的注入执行结果。
const results = await chrome . scripting . executeScript ( {
target : { tabId } ,
func : ( targetSelector : string ) = > Boolean ( document . querySelector ( targetSelector ) ) ,
@@ -233,9 +263,11 @@ async function checkSelectorExists(tabId: number, selector: string): Promise<boo
return Boolean ( results [ 0 ] ? . result ) ;
}
/** 注入 domScraper 到目标页面,并根据当前 step.fields 提取页面数据。 */
/**
* 注入 domScraper 到目标页面,并根据当前 step.fields 提取页面数据。
*/
async function scrapeStepFields ( tabId : number , step : PlatformStepConfig ) : Promise < DomScrapeResult | null > {
/** 目标页面执行 DOM 抓取后返回的结果数组。 */
// 目标页面执行 DOM 抓取后返回的结果数组。
const results = await chrome . scripting . executeScript ( {
target : { tabId } ,
func : scrapeDomFields ,
@@ -245,14 +277,18 @@ async function scrapeStepFields(tabId: number, step: PlatformStepConfig): Promis
return results [ 0 ] ? . result ? ? null ;
}
/** 判断指定任务是否仍处于 running 状态。 */
/**
* 判断指定任务是否仍处于 running 状态。
*/
async function isTaskRunning ( taskId : string ) : Promise < boolean > {
/** 当前 storage 中的任务状态。 */
// 当前 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 ,
@@ -266,7 +302,9 @@ async function markStepRunning(taskId: string, stepIndex: number): Promise<void>
} ) ) ;
}
/** 将指定步骤标记为成功。 */
/**
* 将指定步骤标记为成功。
*/
async function markStepSuccess ( taskId : string , stepIndex : number ) : Promise < void > {
await updateCrawlTaskState ( taskId , ( state ) = > ( {
. . . state ,
@@ -276,7 +314,9 @@ async function markStepSuccess(taskId: string, stepIndex: number): Promise<void>
} ) ) ;
}
/** 将指定步骤标记为失败,并记录失败原因。 */
/**
* 将指定步骤标记为失败,并记录失败原因。
*/
async function markStepFailed ( taskId : string , stepIndex : number , message : string ) : Promise < void > {
await updateCrawlTaskState ( taskId , ( state ) = > ( {
. . . state ,
@@ -287,7 +327,9 @@ async function markStepFailed(taskId: string, stepIndex: number, message: string
} ) ) ;
}
/** 将整个任务标记为完成。 */
/**
* 将整个任务标记为完成。
*/
async function markTaskCompleted ( taskId : string ) : Promise < void > {
await updateCrawlTaskState ( taskId , ( state ) = > ( {
. . . state ,
@@ -296,7 +338,9 @@ async function markTaskCompleted(taskId: string): Promise<void> {
} ) ) ;
}
/** 将整个任务标记为失败。 */
/**
* 将整个任务标记为失败。
*/
async function markTaskFailed ( taskId : string , message = '爬取失败' ) : Promise < void > {
await updateCrawlTaskState ( taskId , ( state ) = > ( {
. . . state ,
@@ -307,12 +351,14 @@ async function markTaskFailed(taskId: string, message = '爬取失败'): Promise
} ) ) ;
}
/** 读取任务状态后执行不可变更新,避免覆盖已取消或已替换的任务。 */
/**
* 读取任务状态后执行不可变更新,避免覆盖已取消或已替换的任务。
*/
async function updateCrawlTaskState (
taskId : string ,
updater : ( state : CrawlTaskState ) = > CrawlTaskState ,
) : Promise < void > {
/** 当前 storage 中最新的任务状态。 */
// 当前 storage 中最新的任务状态。
const state = await getCrawlTaskState ( ) ;
if ( ! state || state . id !== taskId || state . status === 'canceled' ) {
@@ -322,23 +368,28 @@ async function updateCrawlTaskState(
await setCrawlTaskState ( updater ( state ) ) ;
}
/** 睡眠工具,用于轮询 DOM 等待。 */
/**
* 睡眠工具,用于轮询 DOM 等待。
*/
function sleep ( ms : number ) : Promise < void > {
return new Promise ( ( resolve ) = > {
globalThis . setTimeout ( resolve , ms ) ;
} ) ;
}
/** 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。 */
/**
* 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。
*/
async function cancelCrawl ( ) : Promise < CrawlStateResponse > {
/** 当前保存的爬取任务状态。 */
// 当前保存的爬取任务状态。
const state = await getCrawlTaskState ( ) ;
if ( ! state ) {
return { ok : true , data : null } ;
}
/** 用户取消后的任务状态,当前执行步骤会显示为失败并附带取消原因。 */
// 用户取消后的任务状态。
// 当前执行步骤会显示为失败并附带取消原因。
const canceledState : CrawlTaskState = {
. . . state ,
status : 'canceled' ,
@@ -356,21 +407,28 @@ async function cancelCrawl(): Promise<CrawlStateResponse> {
return { ok : true , data : canceledState } ;
}
/** 从 chrome.storage.local 读取当前爬取任务状态。 */
/**
* 从 chrome.storage.local 读取当前爬取任务状态。
*/
async function getCrawlTaskState ( ) : Promise < CrawlTaskState | null > {
/** chrome.storage.local 返回的原始键值对象。 */
// chrome.storage.local 返回的原始键值对象。
const result = await chrome . storage . local . get ( CRAWL_TASK_STORAGE_KEY ) ;
/** 取出的任务状态候选值,需要经过结构校验后才能使用。 */
// 取出的任务状态候选值。
// 需要经过结构校验后才能使用。
const state = result [ CRAWL_TASK_STORAGE_KEY ] ;
return isCrawlTaskState ( state ) ? state : null ;
}
/** 将最新爬取任务状态写入 chrome.storage.local, 供 popup 和 content script 同步读取。 */
/**
* 将最新爬取任务状态写入 chrome.storage.local, 供 popup 和 content script 同步读取。
*/
async function setCrawlTaskState ( state : CrawlTaskState ) : Promise < void > {
await chrome . storage . local . set ( { [ CRAWL_TASK_STORAGE_KEY ] : state } ) ;
}
/** 打开一个普通浏览器窗口承载目标平台页面。 */
/**
* 打开一个普通浏览器窗口承载目标平台页面。
*/
function createCrawlWindow ( url : string ) : Promise < chrome.windows.Window > {
return new Promise ( ( resolve , reject ) = > {
chrome . windows . create (
@@ -382,7 +440,7 @@ function createCrawlWindow(url: string): Promise<chrome.windows.Window> {
height : 900 ,
} ,
( windowInfo ) = > {
/** Chrome 扩展 API 回调中的运行时错误。 */
// Chrome 扩展 API 回调中的运行时错误。
const runtimeError = chrome . runtime . lastError ;
if ( runtimeError ) {
@@ -401,7 +459,9 @@ function createCrawlWindow(url: string): Promise<chrome.windows.Window> {
} ) ;
}
/** 根据窗口 ID 关闭爬取窗口;关闭失败时不阻塞取消状态写入。 */
/**
* 根据窗口 ID 关闭爬取窗口;关闭失败时不阻塞取消状态写入。
*/
function removeWindow ( windowId : number ) : Promise < void > {
return new Promise ( ( resolve ) = > {
chrome . windows . remove ( windowId , ( ) = > {
@@ -410,7 +470,9 @@ function removeWindow(windowId: number): Promise<void> {
} ) ;
}
/** 粗略判断 storage 中读取到的值是否像一个爬取任务状态对象。 */
/**
* 粗略判断 storage 中读取到的值是否像一个爬取任务状态对象。
*/
function isCrawlTaskState ( value : unknown ) : value is CrawlTaskState {
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value ;
}