Skip to main content
Glama

McpDocServer

by ruan11223344
task-manager.js121 kB
import { BrowserManager } from './browser-manager.js'; import fs from 'fs/promises'; import * as fsSync from 'fs'; // 引入同步版本的fs模块 import path from 'path'; import { docSources, crawlerConfig } from '../config/doc-sources.js'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; export class TaskManager { constructor() { this.tasks = new Map(); // 存储任务的Map this.browserManager = new BrowserManager(); // 浏览器管理器 this.initialized = false; // 初始化标志 this.config = crawlerConfig; // 爬虫配置 this.activePages = 0; // 当前活动的页面数 this.pendingUrls = new Map(); // 待处理的URL集合,key为url,value为状态对象 this.processingUrls = new Set(); // 正在处理的URL集合 this.savedPages = new Map(); // 跟踪哪些URL已成功保存 this.pendingSaves = new Map(); // 跟踪待保存操作 this.fileLocks = new Map(); // 文件锁 this.saveQueue = new Map(); // 文件保存队列 this.lastSavePromise = new Map(); // 每个文件的最后一次保存操作 } /** * 初始化任务管理器 */ async init() { if (this.initialized) return; try { await this.browserManager.init(); console.log('[任务] 浏览器初始化成功'); this.initialized = true; } catch (error) { console.error('[任务] 初始化失败:', error); throw error; } } /** * 保存页面数据 * @param {string} sourceName - 源名称 * @param {string} url - 页面URL * @param {object} pageData - 页面数据 */ async savePage(sourceName, url, pageData) { try { // 确保sourceName有效 if (!sourceName) { console.error(`[保存] 无效的sourceName: ${sourceName}`); return; } // 获取当前脚本的目录 const scriptDir = dirname(fileURLToPath(import.meta.url)); // 获取项目根目录 const projectRoot = path.join(scriptDir, '..'); // 确保docs目录存在 const docsDir = path.join(projectRoot, 'docs'); await fs.mkdir(docsDir, { recursive: true }).catch(() => {}); // 构建输出文件路径 - 直接使用.json扩展名 const outputPath = path.join(docsDir, `${sourceName.toLowerCase()}-docs.json`); console.log(`[配置] 将为 ${sourceName} 保存文档至: ${outputPath}`); // 首先,保存到内存缓存 if (!this.savedPages.has(sourceName)) { this.savedPages.set(sourceName, new Map()); } // 添加到内存缓存 this.savedPages.get(sourceName).set(url, pageData); // 注册此页面以进行保存 if (!this.pendingSaves.has(sourceName)) { this.pendingSaves.set(sourceName, new Set()); } this.pendingSaves.get(sourceName).add(url); // 安排保存操作(防抖动) if (!this._savePromises) this._savePromises = new Map(); if (this._savePromises.has(sourceName)) { // 如果已经安排了保存,让它处理这个 return this._savePromises.get(sourceName); } // 安排一个带防抖动的新保存 const savePromise = new Promise(resolve => { setTimeout(async () => { try { await this._performActualSave(sourceName, outputPath); resolve(); } catch (err) { console.error(`[错误] ${sourceName}保存失败:`, err); // 即使保存失败,也要进行重试 try { console.log(`[重试] 尝试再次保存 ${sourceName}...`); // 等待一段时间再重试 await new Promise(r => setTimeout(r, 3000)); await this._performActualSave(sourceName, outputPath); console.log(`[恢复] ${sourceName}重试保存成功`); resolve(); } catch (retryError) { console.error(`[严重错误] ${sourceName}重试保存也失败: ${retryError.message}`); // 尝试保存到备用位置 try { const backupPath = `${outputPath}.backup-${Date.now()}.json`; console.log(`[紧急备份] 尝试保存到备用位置: ${backupPath}`); // 准备数据 const backupData = { source: { name: sourceName, url: this.tasks.get(sourceName)?.url || '' }, lastUpdated: new Date().toISOString(), pages: {} }; // 将已保存的页面添加到备份数据 const savedPagesForSource = this.savedPages.get(sourceName); if (savedPagesForSource) { for (const [pageUrl, pageData] of savedPagesForSource.entries()) { try { const safePageData = this.ensureSafeJsonData(pageData); backupData.pages[pageUrl] = safePageData; } catch (e) { console.warn(`[警告] 无法将页面添加到备份: ${pageUrl}`); } } } // 写入备份文件 await fs.writeFile(backupPath, JSON.stringify(backupData, null, 2), 'utf-8'); console.log(`[紧急备份] 成功保存到: ${backupPath}`); } catch (backupError) { console.error(`[紧急备份失败] ${backupError.message}`); } resolve(); // 仍然解析以允许将来保存 } } finally { // 允许安排新的保存 this._savePromises.delete(sourceName); } }, 1000); // 等待1秒钟以收集多个保存 }); this._savePromises.set(sourceName, savePromise); return savePromise; } catch (error) { console.error(`[错误] 保存页面失败: ${error.message}`); } } /** * 执行实际文件保存的方法 (修复后的版本) * @param {string} sourceName - 源名称 * @param {string} outputPath - 输出文件路径 */ async _performActualSave(sourceName, outputPath) { // 为此文件创建锁 const lockFile = `${outputPath}.lock`; try { // 尝试创建锁文件(原子操作) await fs.writeFile(lockFile, Date.now().toString(), { flag: 'wx' // 如果文件存在则失败 }).catch(async err => { if (err.code === 'EEXIST') { // 锁存在,检查它是否过时(超过30秒) try { const lockStat = await fs.stat(lockFile); const lockAge = Date.now() - lockStat.mtime.getTime(); if (lockAge > 30000) { // 过时的锁,覆盖它 console.log(`[锁] 删除${sourceName}的过时锁`); await fs.unlink(lockFile).catch(() => {}); await fs.writeFile(lockFile, Date.now().toString()); } else { // 最近的锁,等待并重试 console.log(`[锁] 等待${sourceName}的锁`); await new Promise(r => setTimeout(r, 2000)); throw new Error('锁存在'); } } catch (lockErr) { // 如果我们无法检查锁,等待并重试 await new Promise(r => setTimeout(r, 2000)); throw new Error('锁检查失败'); } } else { throw err; } }); // 获取待保存的URL const pendingUrls = Array.from(this.pendingSaves.get(sourceName) || []); if (pendingUrls.length === 0) { console.log(`[保存] ${sourceName}没有待处理的URL`); return; } console.log(`[保存] 正在为${sourceName}保存${pendingUrls.length}个页面`); // 读取现有数据或创建新结构 let existingData = { source: { name: sourceName, url: this.tasks.get(sourceName)?.url || '' }, lastUpdated: new Date().toISOString(), pages: {} }; // 如果文件存在,尝试读取 let existingFile = false; try { if (await fs.access(outputPath).then(() => true).catch(() => false)) { existingFile = true; const content = await fs.readFile(outputPath, 'utf-8'); // 优先尝试作为JSON直接解析 try { const parsed = JSON.parse(content); if (parsed && typeof parsed === 'object') { existingData = { source: parsed.source || existingData.source, lastUpdated: new Date().toISOString(), pages: parsed.pages || {} }; console.log(`[加载] 成功解析JSON数据,包含${Object.keys(existingData.pages || {}).length}个页面`); } } catch (jsonError) { console.log(`[信息] 不是有效的JSON文件,尝试其他格式: ${jsonError.message}`); // 如果不是有效的JSON,尝试匹配export default到最后一个分号 try { const match = content.match(/export\s+default\s*({[\s\S]*});/); if (match && match[1]) { try { // 使用Function构造函数安全地执行JavaScript (比eval安全) const objStr = `return ${match[1]}`; const parseFn = new Function(objStr); const parsed = parseFn(); if (parsed && typeof parsed === 'object') { // 确保结构完整 existingData = { source: parsed.source || existingData.source, lastUpdated: new Date().toISOString(), pages: parsed.pages || {} }; console.log(`[加载] 成功解析JS模块数据,包含${Object.keys(existingData.pages || {}).length}个页面`); } } catch (fnError) { console.warn(`[警告] Function解析失败: ${fnError.message}`); // 后备方法: 尝试JSON解析 try { // 修复常见的JSON解析问题 let jsonText = match[1] // 确保所有键都有双引号 .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // 单引号变双引号 .replace(/'/g, '"') // 移除尾随逗号 .replace(/,(\s*[\]}])/g, '$1') // 修复可能的无引号字符串值 .replace(/:(\s*)([^"{}\[\],\s][^,{}\[\]]*?)(\s*)(,|}|])/g, ':"$2"$3$4'); const parsed = JSON.parse(jsonText); if (parsed && typeof parsed === 'object') { existingData = { source: parsed.source || existingData.source, lastUpdated: new Date().toISOString(), pages: parsed.pages || {} }; console.log(`[加载] 通过JSON方法成功解析数据,包含${Object.keys(existingData.pages || {}).length}个页面`); } } catch (jsonError) { console.warn(`[警告] 所有解析方法都失败: ${jsonError.message}`); // 如果现有文件解析失败,创建备份,以便检查问题 const errorBackupPath = `${outputPath}.error-${Date.now()}.bak`; await fs.copyFile(outputPath, errorBackupPath).catch(() => {}); console.log(`[错误备份] 已创建错误备份: ${errorBackupPath}`); // 继续使用空对象 console.log(`[注意] 将使用新的空数据结构`); } } } else { console.log(`[信息] 未找到export default语句,使用新的数据结构`); } } catch (parseErr) { console.warn(`[警告] 解析现有文件失败: ${parseErr.message}`); } } } } catch (readErr) { console.warn(`[警告] 读取现有文件失败: ${readErr.message}`); } // 确保pages存在 existingData.pages = existingData.pages || {}; // 用待处理的页面更新 let updateCount = 0; // 检测内容相似性的辅助函数 const isSimilarContent = (content1, content2) => { if (!content1 || !content2) return false; // 如果两个内容完全相同,返回true if (content1 === content2) return true; // 计算相似度(简化版) const words1 = content1.split(/\s+/).filter(w => w.length > 3); const words2 = content2.split(/\s+/).filter(w => w.length > 3); // 如果字数差异太大,认为内容不同 if (Math.abs(words1.length - words2.length) > words1.length * 0.3) return false; // 计算共同单词数 const set1 = new Set(words1); const commonWords = words2.filter(word => set1.has(word)).length; // 如果共同单词比例超过70%,认为内容相似 return commonWords > words2.length * 0.7; }; // 检查是否有内容相似的页面 const findSimilarPage = (url, pageData) => { for (const [existingUrl, pageDataObj] of Object.entries(existingData.pages)) { // 跳过自身 if (existingUrl === url) continue; // 检查标题和内容是否相似 if (pageData.title === pageDataObj.title && isSimilarContent(pageData.content, pageDataObj.content)) { return existingUrl; } } return null; }; for (const url of pendingUrls) { const pageData = this.savedPages.get(sourceName)?.get(url); if (pageData) { try { // 确保所有字段安全可序列化 const safePageData = this.ensureSafeJsonData(pageData); // 检查是否有内容相似的页面已经存在 const similarUrl = findSimilarPage(url, safePageData); if (similarUrl) { console.log(`[相似] 发现相似页面 ${url} 与 ${similarUrl},跳过保存`); continue; // 跳过保存 } // 确保页面数据至少包含必要字段 const finalPageData = { title: safePageData.title || '无标题', content: safePageData.content || '' }; // 只更新现有数据,而不是替换整个对象 existingData.pages[url] = finalPageData; updateCount++; } catch (dataError) { console.warn(`[警告] 处理页面数据时出错 ${url}: ${dataError.message}`); // 尝试使用最小数据 try { // 确保至少有标题和内容的占位符 existingData.pages[url] = { title: (pageData && pageData.title) ? pageData.title : `页面 ${url.split('/').pop() || '无标题'}`, content: (pageData && pageData.content) ? pageData.content : `加载此页面时出错: ${dataError.message}` }; updateCount++; } catch (fallbackError) { console.error(`[错误] 无法保存基本数据 ${url}: ${fallbackError.message}`); } } } } // 如果没有更新任何页面,提前返回,避免重写文件 if (updateCount === 0) { console.log(`[跳过] ${sourceName} 没有新页面需要保存`); return; } // 更新时间戳 existingData.lastUpdated = new Date().toISOString(); // 格式化JSON,添加错误处理 let formattedJson; try { formattedJson = JSON.stringify(existingData, null, 2); } catch (jsonError) { console.error(`[严重错误] JSON序列化失败,尝试修复数据: ${jsonError.message}`); // 尝试使用更安全的序列化方法 try { // 创建一个安全版本的数据对象 const safeData = { source: existingData.source || { name: sourceName, url: this.tasks.get(sourceName)?.url || '' }, lastUpdated: new Date().toISOString(), pages: {} }; // 只保留能够安全序列化的页面 for (const [pageUrl, pageData] of Object.entries(existingData.pages || {})) { try { // 尝试序列化每个页面,如果失败则跳过 const testJson = JSON.stringify(this.ensureSafeJsonData(pageData)); safeData.pages[pageUrl] = JSON.parse(testJson); } catch (e) { console.warn(`[警告] 页面 ${pageUrl} 无法序列化,将被跳过: ${e.message}`); } } formattedJson = JSON.stringify(safeData, null, 2); console.log(`[恢复] 成功恢复数据,保留了 ${Object.keys(safeData.pages).length} 个页面`); } catch (fallbackError) { // 如果所有尝试都失败,创建一个最小化的有效数据 console.error(`[严重错误] 无法恢复数据: ${fallbackError.message}`); formattedJson = JSON.stringify({ source: { name: sourceName, url: this.tasks.get(sourceName)?.url || '' }, lastUpdated: new Date().toISOString(), pages: {} }, null, 2); } } // 确保输出路径是有效的 const jsonOutputPath = outputPath; // 输出实际使用的路径用于调试 console.log(`[路径] 文档将保存至: ${jsonOutputPath}`); const jsonTempPath = `${jsonOutputPath}.new`; const backupPath = `${jsonOutputPath}.bak`; // 如果原始文件存在,创建备份 const jsonExists = await fs.access(jsonOutputPath).then(() => true).catch(() => false); if (jsonExists) { await fs.copyFile(jsonOutputPath, backupPath).catch(() => {}); } // 写入JSON临时文件 await fs.writeFile(jsonTempPath, formattedJson, 'utf-8'); // 将JSON临时文件重命名为实际文件(原子操作) await fs.rename(jsonTempPath, jsonOutputPath); console.log(`[保存] 已保存为JSON格式: ${jsonOutputPath}`); // 删除备份文件 if (fsSync.existsSync(backupPath)) { await fs.unlink(backupPath).catch(err => console.warn(`[警告] 无法删除备份文件 ${backupPath}: ${err.message}`)); } // 成功!从待处理列表中清除已保存的页面 for (const url of pendingUrls) { this.pendingSaves.get(sourceName)?.delete(url); } console.log(`[保存完成] ${sourceName} - 已保存${updateCount}个页面,总计: ${Object.keys(existingData.pages).length}`); } catch (error) { console.error(`[保存错误] 保存${sourceName}失败:`, error); // 不清除待处理的URL - 它们将在下次重试 } finally { // 移除锁文件 await fs.unlink(lockFile).catch(() => {}); } } /** * 确保数据可以安全序列化为JSON * @param {object} data - 要处理的数据 * @returns {object} 安全的可序列化数据 */ ensureSafeJsonData(data) { if (!data) return data; // 如果是字符串,确保转义特殊字符 if (typeof data === 'string') { return data .replace(/\\/g, '\\\\') // 先转义反斜杠 .replace(/"/g, '\\"') // 转义双引号 .replace(/\n/g, '\\n') // 转义换行符 .replace(/\r/g, '\\r') // 转义回车符 .replace(/\t/g, '\\t') // 转义制表符 .replace(/\f/g, '\\f'); // 转义换页符 } // 如果是对象,递归处理所有属性 if (typeof data === 'object' && data !== null) { const result = Array.isArray(data) ? [] : {}; for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { result[key] = this.ensureSafeJsonData(data[key]); } } return result; } // 其他类型直接返回 return data; } /** * 获取标准化的URL,移除参数、锚点和尾部斜杠 * @param {string} url - 原始URL * @returns {string} 标准化的URL */ getNormalizedUrl(url) { try { const urlObj = new URL(url); // 只保留协议、主机和路径部分,移除查询参数和锚点 let normalizedUrl = `${urlObj.protocol}//${urlObj.hostname}${urlObj.pathname}`; // 移除末尾的斜杠 normalizedUrl = normalizedUrl.replace(/\/$/, ''); return normalizedUrl; } catch (error) { console.error(`[错误] 标准化URL失败 (${url}): ${error.message}`); return url; } } /** * 获取不带锚点的URL * @param {string} url - 原始URL * @returns {string} 不带锚点的URL */ getUrlWithoutHash(url) { try { const urlObj = new URL(url); return `${urlObj.origin}${urlObj.pathname}${urlObj.search}`; } catch (error) { console.error(`[错误] 解析URL失败 (${url}): ${error.message}`); return url; } } /** * 处理单个URL * @param {string} url - 要处理的URL * @param {object} taskGroup - 任务组 */ async processUrl(url, taskGroup) { // 获取标准化的URL const normalizedUrl = this.getNormalizedUrl(url); // 检查是否已经处理过 if (this.processingUrls.has(normalizedUrl) || taskGroup.pages.has(normalizedUrl)) { console.log(`[跳过] ${url} 已处理或正在处理中`); return; } let page = null; let retryCount = 0; const maxRetries = 3; while (retryCount < maxRetries) { try { this.processingUrls.add(normalizedUrl); this.activePages++; console.log(`\n[爬取] ${taskGroup.name} - ${url} (当前活动页面: ${this.activePages}, 重试次数: ${retryCount})`); // 每次重试都重新获取页面,避免使用分离的Frame if (page) { try { await page.close().catch(() => {}); } catch (error) { console.log(`[关闭] 关闭旧页面失败: ${error.message}`); } page = null; } page = await this.browserManager.getPage(url); // 使用更可靠的方式等待页面加载 try { // 采用更通用的等待策略 await page.goto(url, { waitUntil: 'networkidle0', // 使用networkidle0等待所有网络请求完成 timeout: 30000 // 30秒超时 }).catch(error => { console.log(`[警告] 页面导航错误,但将继续处理: ${error.message}`); }); // 确保body元素加载完成 await page.waitForSelector('body', { timeout: 10000 }).catch(() => { console.log(`[警告] 等待页面body元素超时: ${url}`); }); // 等待动态内容加载 await this.waitForDynamicContent(page); } catch (navigationError) { console.log(`[警告] 页面加载错误,但将继续处理: ${navigationError.message}`); } // 添加try-catch保护所有页面操作 try { // 检查页面是否可用 const isPageValid = await page.evaluate(() => true).catch(() => false); if (!isPageValid) { throw new Error("页面不可用,可能已分离"); } // 直接从DOM中提取所有链接 const links = await page.evaluate(() => { const linkSet = new Set(); try { // 获取所有链接 const getAllLinks = (node) => { if (!node) return; try { // 获取所有a标签 const links = node.getElementsByTagName('a'); if (links) { for (const link of links) { const href = link.href; if (href && !href.startsWith('javascript:') && !href.startsWith('#')) { linkSet.add(href); } } } // 获取所有可能包含链接的元素 const elements = node.getElementsByTagName('*'); if (elements) { for (const element of elements) { if (!element) continue; try { // 检查data-href属性 const dataHref = element.getAttribute('data-href'); if (dataHref && !dataHref.startsWith('javascript:') && !dataHref.startsWith('#')) { linkSet.add(dataHref); } // 检查data-url属性 const dataUrl = element.getAttribute('data-url'); if (dataUrl && !dataUrl.startsWith('javascript:') && !dataUrl.startsWith('#')) { linkSet.add(dataUrl); } // 检查data-link属性 const dataLink = element.getAttribute('data-link'); if (dataLink && !dataLink.startsWith('javascript:') && !dataLink.startsWith('#')) { linkSet.add(dataLink); } // 检查href属性 const href = element.getAttribute('href'); if (href && !href.startsWith('javascript:') && !href.startsWith('#')) { linkSet.add(href); } } catch (elementError) { // 忽略单个元素的错误 console.error(`处理元素属性时出错: ${elementError.message}`); } } } } catch (nodeError) { // 忽略单个节点的错误 console.error(`处理节点时出错: ${nodeError.message}`); } }; // 从body开始遍历 if (document.body) { getAllLinks(document.body); } // 获取所有可能的内容区域 let contentAreas = []; try { contentAreas = [ document.body, ...Array.from(document.querySelectorAll('article, main, .content, .doc-content, .markdown-body') || []), ...Array.from(document.querySelectorAll('[class*="content" i], [class*="article" i], [class*="main" i]') || []), ...Array.from(document.querySelectorAll('.menu, .nav, .sidebar, .toc, .doc-nav') || []), ...Array.from(document.querySelectorAll('[class*="menu" i], [class*="nav" i], [class*="sidebar" i], [class*="toc" i]') || []) ].filter(area => area); } catch (queryError) { console.error(`查询内容区域时出错: ${queryError.message}`); contentAreas = document.body ? [document.body] : []; } // 从每个区域提取链接 contentAreas.forEach(area => { if (area) { try { getAllLinks(area); } catch (areaError) { console.error(`处理区域时出错: ${areaError.message}`); } } }); } catch (globalError) { console.error(`提取链接时出错: ${globalError.message}`); } return Array.from(linkSet); }).catch(error => { console.warn(`[警告] 提取链接失败,将使用空数组: ${error.message}`); return []; }); // 过滤同域名的链接 const baseUrl = new URL(taskGroup.url); const sameDomainLinks = links.filter(link => { try { const targetUrl = new URL(link); return targetUrl.hostname === baseUrl.hostname; } catch (e) { return false; } }); console.log(`[链接] 发现 ${sameDomainLinks.length} 个同域名链接`); // 提取页面内容 const pageData = await page.evaluate(() => { try { // 获取标题 const title = document.title || ''; // 获取内容 let content = ''; // 先移除所有script和style标签 const tempDoc = document.cloneNode(true); // 只移除非文档内容的脚本和样式 tempDoc.querySelectorAll('script:not([type="text/example"]):not([class*="example"]):not([data-type="example"]), style:not([data-example]), link[rel="stylesheet"]').forEach(el => el.remove()); // 保留可能是文档示例的代码,但标记它们 tempDoc.querySelectorAll('pre, code, [class*="example"], [class*="demo"], [class*="snippet"], [data-lang]').forEach(el => { el.setAttribute('data-is-example', 'true'); }); // 尝试获取主要内容区域 let mainContent = null; try { mainContent = tempDoc.querySelector('.markdown-body, .doc-content, article, main, .content'); } catch (queryError) { console.error(`查询主内容区域失败: ${queryError.message}`); } if (mainContent) { try { content = mainContent.innerText || ''; } catch (textError) { console.error(`提取主内容文本失败: ${textError.message}`); content = ''; } } else { // 如果没有找到主要内容区域,尝试获取所有内容 try { if (tempDoc.body) { // 移除页面中常见的非文档相关元素 const nonDocElements = tempDoc.body.querySelectorAll('nav, header, footer, aside, .sidebar, .navigation, .menu, .ads, .banner, .cookie-notice, .modal'); nonDocElements.forEach(el => el.remove()); // 更智能地处理代码元素 - 保留文档中的代码示例 const codeElements = tempDoc.body.querySelectorAll('pre:not([data-is-example="true"]), code:not([data-is-example="true"])'); codeElements.forEach(el => { // 检查是否在文档内容区域内 const isInContent = el.closest('.doc-content, .markdown-body, article, main, .content, [class*="example"], [class*="demo"]'); // 检查内容是否看起来像示例代码 const textContent = el.textContent || ''; const looksLikeExample = (textContent.includes('function') && textContent.includes('{') && textContent.includes('}')) || (textContent.includes('class') && textContent.includes('{')) || (textContent.includes('<') && textContent.includes('>')) || (textContent.includes('@media') || textContent.includes('@import')) || (textContent.includes('const ') || textContent.includes('let ')) || (textContent.includes('.css') || textContent.includes('.js')); // 如果不在内容区域内且不像示例代码,则移除 if (!isInContent && !looksLikeExample) { el.remove(); } else { // 如果像示例代码,标记它以便保留 el.setAttribute('data-is-example', 'true'); } }); content = tempDoc.body.innerText || ''; } else { content = ''; } } catch (bodyTextError) { console.error(`提取body文本失败: ${bodyTextError.message}`); content = ''; } } // 清理内容 - 增强版 content = content .replace(/\n{3,}/g, '\n\n') // 将多个连续换行替换为两个换行 .replace(/\s+/g, ' ') // 将多个连续空格替换为单个空格 .replace(/[{}]/g, match => '\\' + match) // 转义花括号 .replace(/["\\]/g, match => '\\' + match) // 转义引号和反斜杠 // 删除可能的CSS样式和JavaScript代码片段,但避免删除示例代码 .replace(/(?<!example|demo|snippet)[\s\n]*{[\s\S]*?}/g, function(match) { // 检查上下文判断是否是示例代码 if (match.includes('function') || match.includes('class') || match.includes('const ') || match.includes('let ') || match.includes('var ') || match.includes('return ') || match.includes('@media') || match.includes('import ')) { return match; // 保留可能的示例代码 } return ''; // 删除非示例代码 }) .replace(/(?<!\/\/\s*example[\s\n]*)function\s*\([\s\S]*?\)\s*{[\s\S]*?}/g, function(match) { // 检查是否有示例代码注释 if (match.includes('// example') || match.includes('/* example') || match.includes('// 示例') || match.includes('/* 示例')) { return match; // 保留有示例注释的代码 } return ''; // 删除非示例代码 }) // 保留框架特定的语法 .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*var\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*const\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*let\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*import\s+.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*export\s+.*;/g, '') // 谨慎处理注释,避免删除有用的文档注释 .replace(/\/\*(?!\s*example)[\s\S]*?\*\//g, '') .replace(/(?<!example|demo|snippet)[\s\n]*\/\/(?!\s*example).*\n/g, '\n') .trim(); // 确保title也是安全的 const safeTitle = title ? title.replace(/[{}]/g, match => '\\' + match) .replace(/["\\]/g, match => '\\' + match) : ''; return { title: safeTitle, content: content }; } catch (globalError) { console.error(`提取页面内容时出错: ${globalError.message}`); // 返回空数据,避免完全失败 return { title: '页面加载错误', content: `无法提取内容: ${globalError.message}` }; } }).catch(error => { console.warn(`[警告] 提取内容失败,将使用空数据: ${error.message}`); return { title: '页面提取失败', content: `提取内容错误: ${error.message}` }; }); // 检查内容是否为空或太少(可能是动态加载尚未完成) if (pageData.content.length < 500) { // 只用字符数量判断,500字符以下认为内容不完整 console.log(`[动态内容] 检测到内容较少(${pageData.content.length}字符),可能是动态加载尚未完成,尝试使用多种方法重新抓取...`); // 1. 尝试点击可能的页面元素来激活内容 try { await page.evaluate(() => { // 尝试点击可能是内容触发器的元素 const potentialTriggers = [ '.content-trigger', '.doc-content', 'article', 'main', '.content', '[role="main"]', '[role="article"]', '.article-content', '.doc-body', '.markdown-body', '.documentation', '#content' ]; potentialTriggers.forEach(selector => { const elements = document.querySelectorAll(selector); if (elements && elements.length) { elements.forEach(el => el.click()); } }); // 尝试点击页面上所有按钮和链接 document.querySelectorAll('button, .btn, [role="button"]').forEach(btn => { // 排除导航和菜单按钮 if (!btn.closest('nav, header, .menu, .navigation')) { btn.click(); } }); }).catch(e => console.log(`[点击] 点击元素时出错: ${e.message}`)); // 等待可能的交互反应 await new Promise(resolve => setTimeout(resolve, 2000)); } catch (err) { console.log(`[警告] 尝试点击页面元素失败: ${err.message}`); } // 2. 尝试查找并切换到包含内容的iframe try { const frames = await page.frames(); if (frames.length > 1) { console.log(`[iframe] 发现页面中有${frames.length}个iframe,尝试从中提取内容`); // 提取所有iframe的内容 for (const frame of frames) { try { // 检查iframe是否包含实际内容 const frameContent = await frame.evaluate(() => { const text = document.body ? document.body.innerText : ''; return { text: text, length: text.length, hasContent: text.length > 200 }; }); if (frameContent.hasContent) { console.log(`[iframe] 在iframe中发现内容,长度: ${frameContent.length}字符`); // 从iframe中提取内容 const iframeData = await frame.evaluate(() => { // 获取标题 const title = document.title || ''; // 获取内容 let content = ''; // 先移除所有script和style标签 const tempDoc = document.cloneNode(true); // 只移除非文档内容的脚本和样式 tempDoc.querySelectorAll('script:not([type="text/example"]):not([class*="example"]):not([data-type="example"]), style:not([data-example]), link[rel="stylesheet"]').forEach(el => el.remove()); // 保留可能是文档示例的代码,但标记它们 tempDoc.querySelectorAll('pre, code, [class*="example"], [class*="demo"], [class*="snippet"], [data-lang]').forEach(el => { el.setAttribute('data-is-example', 'true'); }); // 尝试获取主要内容区域 let mainContent = null; try { mainContent = tempDoc.querySelector('.markdown-body, .doc-content, article, main, .content'); } catch (queryError) { console.error(`查询主内容区域失败: ${queryError.message}`); } if (mainContent) { try { content = mainContent.innerText || ''; } catch (textError) { console.error(`提取主内容文本失败: ${textError.message}`); content = ''; } } else { // 如果没有找到主要内容区域,尝试获取所有内容 try { if (tempDoc.body) { // 移除页面中常见的非文档相关元素 const nonDocElements = tempDoc.body.querySelectorAll('nav, header, footer, aside, .sidebar, .navigation, .menu, .ads, .banner, .cookie-notice, .modal'); nonDocElements.forEach(el => el.remove()); // 更智能地处理代码元素 - 保留文档中的代码示例 const codeElements = tempDoc.body.querySelectorAll('pre:not([data-is-example="true"]), code:not([data-is-example="true"])'); codeElements.forEach(el => { // 检查是否在文档内容区域内 const isInContent = el.closest('.doc-content, .markdown-body, article, main, .content, [class*="example"], [class*="demo"]'); // 检查内容是否看起来像示例代码 const textContent = el.textContent || ''; const looksLikeExample = (textContent.includes('function') && textContent.includes('{') && textContent.includes('}')) || (textContent.includes('class') && textContent.includes('{')) || (textContent.includes('<') && textContent.includes('>')) || (textContent.includes('@media') || textContent.includes('@import')) || (textContent.includes('const ') || textContent.includes('let ')) || (textContent.includes('.css') || textContent.includes('.js')); // 如果不在内容区域内且不像示例代码,则移除 if (!isInContent && !looksLikeExample) { el.remove(); } else { // 如果像示例代码,标记它以便保留 el.setAttribute('data-is-example', 'true'); } }); content = tempDoc.body.innerText || ''; } else { content = ''; } } catch (bodyTextError) { console.error(`提取body文本失败: ${bodyTextError.message}`); content = ''; } } return { title: title || '', content: content }; }); if (iframeData.content && iframeData.content.length > pageData.content.length) { console.log(`[iframe] 从iframe中提取了更多内容: ${iframeData.content.length}字符`); if (iframeData.title) pageData.title = iframeData.title; pageData.content = iframeData.content; } } } catch (frameErr) { console.log(`[iframe] 访问iframe内容时出错: ${frameErr.message}`); } } } } catch (frameErr) { console.log(`[警告] 处理iframe时出错: ${frameErr.message}`); } // 3. 尝试直接抓取HTML内容并解析,绕过可能的JavaScript保护 try { const htmlContent = await page.content(); console.log(`[HTML解析] 获取页面HTML源码,长度: ${htmlContent.length}字符`); // 尝试使用正则表达式直接从HTML中提取主要内容 // 常见内容容器的模式 const contentPatterns = [ /<article[^>]*>([\s\S]*?)<\/article>/i, /<div[^>]*class="[^"]*content[^"]*"[^>]*>([\s\S]*?)<\/div>/i, /<div[^>]*class="[^"]*doc-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i, /<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i, /<main[^>]*>([\s\S]*?)<\/main>/i, /<div[^>]*id="content"[^>]*>([\s\S]*?)<\/div>/i ]; let bestContent = ''; for (const pattern of contentPatterns) { const match = htmlContent.match(pattern); if (match && match[1]) { // 提取文本内容,移除HTML标签 const extractedText = match[1].replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim(); if (extractedText.length > bestContent.length) { bestContent = extractedText; } } } if (bestContent.length > pageData.content.length * 2) { console.log(`[HTML解析] 直接从HTML中提取到内容: ${bestContent.length}字符`); pageData.content = bestContent; } } catch (htmlErr) { console.log(`[警告] 解析HTML内容时出错: ${htmlErr.message}`); } // 4. 检查是否有特殊API请求获取内容(如Ajax) try { // 使用新的方法确保请求拦截已启用 const interceptEnabled = await this.ensureRequestInterception(page); if (!interceptEnabled) { console.log(`[API] 无法启用请求拦截,跳过API内容检测`); } else { // 监听请求,确保继续处理所有请求 const requestHandler = request => { try { request.continue().catch((continueErr) => { // 检查是否是请求拦截未启用错误 if (continueErr.message && continueErr.message.includes('Request Interception is not enabled')) { console.log(`[API] 请求拦截未启用,跳过请求处理`); return; } try { // 如果continue失败,尝试respond request.respond({ status: 200, contentType: 'text/plain', body: '' }).catch((respondErr) => { // 检查是否是请求拦截未启用错误 if (respondErr.message && respondErr.message.includes('Request Interception is not enabled')) { console.log(`[API] 请求拦截未启用,跳过请求处理`); return; } // 如果respond也失败,尝试abort request.abort().catch(() => {}); }); } catch (e) { console.log(`[API] 处理请求失败: ${e.message}`); } }); } catch (e) { console.log(`[API] 处理请求时出错: ${e.message}`); // 只有在不是拦截未启用错误时才尝试abort if (!e.message || !e.message.includes('Request Interception is not enabled')) { try { request.abort().catch(() => {}); } catch (abortErr) {} } } }; // 添加监听器,确保请求可以继续 page.on('request', requestHandler); // 监听XHR响应 const responsePromise = new Promise((resolve) => { page.once('response', async (response) => { try { const url = response.url(); // 检查是否是API请求或文档内容 if (url.includes('/api/') || url.includes('/docs/') || url.includes('.json') || url.includes('/content/')) { const contentType = response.headers()['content-type'] || ''; if (contentType.includes('json') || contentType.includes('text')) { const text = await response.text(); resolve({ url, text }); } } resolve(null); } catch (e) { resolve(null); } }); }); // 触发可能的Ajax请求 await page.reload({ waitUntil: 'networkidle0' }); // 等待可能的响应 const apiResponse = await Promise.race([ responsePromise, new Promise(resolve => setTimeout(() => resolve(null), 5000)) ]); if (apiResponse && apiResponse.text) { console.log(`[API] 捕获到API响应: ${apiResponse.url}`); try { // 尝试解析JSON const jsonData = JSON.parse(apiResponse.text); // 尝试从JSON提取内容 const contentKeys = ['content', 'body', 'text', 'html', 'data']; for (const key of contentKeys) { if (jsonData[key] && typeof jsonData[key] === 'string' && jsonData[key].length > pageData.content.length) { console.log(`[API] 从API响应中提取内容: ${jsonData[key].length}字符`); pageData.content = jsonData[key]; break; } } } catch (jsonErr) { // 不是JSON,可能是纯文本 if (apiResponse.text.length > pageData.content.length) { console.log(`[API] 使用API响应作为内容: ${apiResponse.text.length}字符`); pageData.content = apiResponse.text; } } } // 清理:移除请求监听器 try { // 使用 off 方法替代 removeListener(Puppeteer最新版本的标准方式) page.off('request', requestHandler); } catch (listenerErr) { // 如果off方法不存在,则使用removeAllListeners(兼容老版本) try { page.removeAllListeners('request'); console.log(`[API] 已移除所有请求监听器`); } catch (e) { console.log(`[API] 无法移除请求监听器: ${e.message}`); } } // 恢复请求拦截状态 if (!interceptEnabled) { try { // 如果我们启用的拦截,需要关闭它 await page.setRequestInterception(false); console.log(`[API] 已关闭请求拦截`); } catch (disableErr) { console.log(`[警告] 关闭请求拦截失败: ${disableErr.message}`); } } } } catch (apiErr) { console.log(`[警告] 监听API请求时出错: ${apiErr.message}`); // 确保请求拦截被关闭,防止后续错误 try { await page.setRequestInterception(false); } catch (e) { // 忽略关闭失败的错误 } } // 5. 最后,尝试常规滚动和重新抓取 await this.scrollPageToLoadLazyContent(page); // 等待足够时间后重新抓取 await new Promise(resolve => setTimeout(resolve, 3000)); // 重新抓取内容 const newPageData = await page.evaluate(() => { try { // 获取标题 const title = document.title || ''; // 获取内容 let content = ''; // 先移除所有script和style标签以及CSS链接 const tempDoc = document.cloneNode(true); // 只移除非文档内容的脚本和样式 tempDoc.querySelectorAll('script:not([type="text/example"]):not([class*="example"]):not([data-type="example"]), style:not([data-example]), link[rel="stylesheet"]').forEach(el => el.remove()); // 保留可能是文档示例的代码,但标记它们 tempDoc.querySelectorAll('pre, code, [class*="example"], [class*="demo"], [class*="snippet"], [data-lang]').forEach(el => { el.setAttribute('data-is-example', 'true'); }); // 尝试获取主要内容区域 let mainContent = null; try { mainContent = tempDoc.querySelector('.markdown-body, .doc-content, article, main, .content'); } catch (queryError) { console.error(`查询主内容区域失败: ${queryError.message}`); } if (mainContent) { try { content = mainContent.innerText || ''; } catch (textError) { console.error(`提取主内容文本失败: ${textError.message}`); content = ''; } } else { // 如果没有找到主要内容区域,尝试获取所有内容 try { if (tempDoc.body) { // 移除页面中常见的非文档相关元素 const nonDocElements = tempDoc.body.querySelectorAll('nav, header, footer, aside, .sidebar, .navigation, .menu, .ads, .banner, .cookie-notice, .modal'); nonDocElements.forEach(el => el.remove()); // 更智能地处理代码元素 - 保留文档中的代码示例 const codeElements = tempDoc.body.querySelectorAll('pre:not([data-is-example="true"]), code:not([data-is-example="true"])'); codeElements.forEach(el => { // 检查是否在文档内容区域内 const isInContent = el.closest('.doc-content, .markdown-body, article, main, .content, [class*="example"], [class*="demo"]'); // 检查内容是否看起来像示例代码 const textContent = el.textContent || ''; const looksLikeExample = (textContent.includes('function') && textContent.includes('{') && textContent.includes('}')) || (textContent.includes('class') && textContent.includes('{')) || (textContent.includes('<') && textContent.includes('>')) || (textContent.includes('@media') || textContent.includes('@import')) || (textContent.includes('const ') || textContent.includes('let ')) || (textContent.includes('.css') || textContent.includes('.js')); // 如果不在内容区域内且不像示例代码,则移除 if (!isInContent && !looksLikeExample) { el.remove(); } else { // 如果像示例代码,标记它以便保留 el.setAttribute('data-is-example', 'true'); } }); content = tempDoc.body.innerText || ''; } else { content = ''; } } catch (bodyTextError) { console.error(`提取body文本失败: ${bodyTextError.message}`); content = ''; } } // 清理内容 - 增强版 content = content .replace(/\n{3,}/g, '\n\n') // 将多个连续换行替换为两个换行 .replace(/\s+/g, ' ') // 将多个连续空格替换为单个空格 .replace(/[{}]/g, match => '\\' + match) // 转义花括号 .replace(/["\\]/g, match => '\\' + match) // 转义引号和反斜杠 // 删除可能的CSS样式和JavaScript代码片段,但避免删除示例代码 .replace(/(?<!example|demo|snippet)[\s\n]*{[\s\S]*?}/g, function(match) { // 检查上下文判断是否是示例代码 if (match.includes('function') || match.includes('class') || match.includes('const ') || match.includes('let ') || match.includes('var ') || match.includes('return ') || match.includes('@media') || match.includes('import ')) { return match; // 保留可能的示例代码 } return ''; // 删除非示例代码 }) .replace(/(?<!\/\/\s*example[\s\n]*)function\s*\([\s\S]*?\)\s*{[\s\S]*?}/g, function(match) { // 检查是否有示例代码注释 if (match.includes('// example') || match.includes('/* example') || match.includes('// 示例') || match.includes('/* 示例')) { return match; // 保留有示例注释的代码 } return ''; // 删除非示例代码 }) // 保留框架特定的语法 .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*var\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*const\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*let\s+\w+\s*=.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*import\s+.*;/g, '') .replace(/(?<!\/\/\s*example[\s\n]*)(?<!example|demo|snippet)[\s\n]*export\s+.*;/g, '') // 谨慎处理注释,避免删除有用的文档注释 .replace(/\/\*(?!\s*example)[\s\S]*?\*\//g, '') .replace(/(?<!example|demo|snippet)[\s\n]*\/\/(?!\s*example).*\n/g, '\n') .replace(/<path[^>]*d=\\?"[^"]*\\?"[^>]*>/g, '') // 移除SVG路径数据 .replace(/d=\\?"[mMlLhHvVcCsSqQtTaAzZ0-9\s,.-]+\\?"/g, '') // 移除SVG路径描述数据 .trim(); // 确保title也是安全的 const safeTitle = title ? title.replace(/[{}]/g, match => '\\' + match) .replace(/["\\]/g, match => '\\' + match) : ''; return { title: safeTitle, content: content }; } catch (globalError) { console.error(`重新提取页面内容时出错: ${globalError.message}`); // 返回空数据,避免完全失败 return { title: '页面重新加载错误', content: `无法重新提取内容: ${globalError.message}` }; } }).catch(error => { console.warn(`[警告] 重新提取内容失败: ${error.message}`); return pageData; // 如果重新提取失败,返回原始数据 }); // 简化判断逻辑:只比较字符长度 if (newPageData.content.length > pageData.content.length * 1.2) { // 内容增加20%以上则认为有效 console.log(`[动态内容] 重新抓取成功!内容从${pageData.content.length}字符增加到${newPageData.content.length}字符`); // 使用新抓取的数据 pageData.title = newPageData.title; pageData.content = newPageData.content; } else { console.log(`[动态内容] 重新抓取未获得明显改进,继续使用原始内容: ${pageData.content.length}字符`); } } // 保存页面数据 const pageDataToSave = { title: pageData.title || `页面 ${normalizedUrl.split('/').pop() || '无标题'}`, content: pageData.content || '页面无内容' }; taskGroup.pages.set(normalizedUrl, pageDataToSave); // 每爬取一个页面就保存一次 await this.savePage(taskGroup.name, normalizedUrl, pageDataToSave); console.log(`[完成] ${taskGroup.name} - ${url}`); // 将新发现的链接添加到待处理集合(仅当页面处理成功时) for (const link of sameDomainLinks) { const normalizedLink = this.getNormalizedUrl(link); // 检查是否是组件链接 if (this.isComponentLink(normalizedLink)) { // 检查是否匹配 excludePatterns const taskGroup = this.getTaskGroupByUrl(normalizedLink); if (taskGroup && taskGroup.excludePatterns && taskGroup.excludePatterns.length > 0) { const urlObj = new URL(normalizedLink); const pathname = urlObj.pathname; const pathWithoutHash = pathname.split('#')[0]; const pathWithoutTrailingSlash = pathWithoutHash.replace(/\/$/, ''); // 如果匹配任何一个 excludePattern,跳过这个链接 if (taskGroup.excludePatterns.some(pattern => pattern.test(pathWithoutTrailingSlash))) { console.log(`[跳过] ${normalizedLink} 匹配排除规则`); continue; } } // 检查是否已经处理过或正在处理 if (!this.processingUrls.has(normalizedLink) && !taskGroup.pages.has(normalizedLink)) { // 检查是否已经在待处理集合中 if (!this.pendingUrls.has(normalizedLink)) { this.pendingUrls.set(normalizedLink, { url: normalizedLink, status: 'pending', retryCount: 0, lastRetry: null }); } } } } // 如果成功处理,跳出重试循环 break; } catch (pageOperationError) { console.error(`[错误] 页面操作失败: ${url}`, pageOperationError); // 这个错误不会重新开始重试循环,而是直接抛出让外部catch捕获 throw pageOperationError; } } catch (error) { console.error(`[错误] 处理页面失败: ${url}`, error); retryCount++; // 更新URL状态为失败 const urlState = this.pendingUrls.get(normalizedUrl); if (urlState) { urlState.status = 'failed'; urlState.error = error.message; urlState.lastRetry = new Date(); } // 如果是Frame分离错误,增加重试间隔 const retryDelay = error.message && error.message.includes('detached Frame') ? retryCount * 5000 // Frame分离错误需要更长时间等待 : retryCount * 2000; // 其他错误使用标准等待时间 // 如果还有重试次数,等待一段时间后重试 if (retryCount < maxRetries) { console.log(`[重试] ${url} 将在 ${retryDelay/1000} 秒后重试...`); await new Promise(resolve => setTimeout(resolve, retryDelay)); } else { // 最后一次尝试失败,但仍然保存一个"错误"页面,避免浪费之前的工作 try { console.log(`[保存错误页面] ${url} 将被保存为错误页面`); const errorPageData = { title: `爬取失败: ${url.split('/').pop() || url}`, content: `处理此页面时发生错误: ${error.message}` }; taskGroup.pages.set(normalizedUrl, errorPageData); // 保存错误页面数据 await this.savePage(taskGroup.name, normalizedUrl, errorPageData); console.log(`[恢复] 已将 ${url} 保存为错误页面`); } catch (saveError) { console.error(`[错误] 无法保存错误页面: ${saveError.message}`); } } } finally { if (page) { try { await page.close().catch(() => {}); console.log(`[关闭] 已关闭页面: ${url}`); } catch (closeError) { console.log(`[警告] 关闭页面时出错: ${closeError.message}`); } } this.processingUrls.delete(normalizedUrl); this.activePages--; console.log(`[状态] 当前活动页面数: ${this.activePages}`); } } } /** * 处理URL队列 */ async processUrlQueue() { while (this.pendingUrls.size > 0 || this.processingUrls.size > 0) { // 如果当前处理的页面数达到限制,等待 while (this.activePages >= this.config.maxConcurrency) { await new Promise(resolve => setTimeout(resolve, 1000)); } // 计算可以同时处理的URL数量 const availableSlots = this.config.maxConcurrency - this.activePages; if (availableSlots <= 0) { await new Promise(resolve => setTimeout(resolve, 1000)); continue; } // 获取待处理的URL const pendingUrls = Array.from(this.pendingUrls.entries()) .filter(([_, state]) => state.status === 'pending') .slice(0, availableSlots); if (pendingUrls.length > 0) { // 并发处理多个URL await Promise.allSettled( pendingUrls.map(async ([url, state]) => { this.pendingUrls.delete(url); for (const [name, taskGroup] of this.tasks) { await this.processUrl(url, taskGroup); } }) ); } else if (this.processingUrls.size === 0) { // 如果没有待处理的URL且没有正在处理的URL,退出循环 break; } else { // 如果还有正在处理的URL,等待一秒后继续检查 await new Promise(resolve => setTimeout(resolve, 1000)); } } } /** * 添加文档源 * @param {object} source - 文档源配置 */ async addSource(source) { try { console.log(`\n[任务] 添加文档源: ${source.name}`); console.log(`[URL] ${source.url}`); const taskGroup = { name: source.name, url: source.url, pages: new Map(), status: 'pending', includePatterns: source.includePatterns, excludePatterns: source.excludePatterns }; this.tasks.set(source.name, taskGroup); // 将起始URL添加到待处理集合 this.pendingUrls.set(source.url, { url: source.url, status: 'pending', retryCount: 0, lastRetry: null }); console.log(`[任务] 文档源添加完成: ${source.name}`); } catch (error) { console.error(`[错误] 初始化文档源失败: ${source.name}`, error); throw error; } } /** * 根据URL获取对应的任务组 * @param {string} url - 要查询的URL * @returns {object|null} 对应的任务组或null */ getTaskGroupByUrl(url) { try { const urlObj = new URL(url); // 遍历所有任务组,找到匹配的配置 for (const [_, taskGroup] of this.tasks) { const baseUrl = new URL(taskGroup.url); if (urlObj.hostname === baseUrl.hostname) { return taskGroup; } } return null; } catch (error) { console.error('获取任务组失败:', error); return null; } } /** * 检查是否是组件链接 * @param {string} url - 要检查的URL * @returns {boolean} 是否是组件链接 */ isComponentLink(url) { try { const urlObj = new URL(url); // 移除hash部分和末尾斜杠,只匹配路径 const pathname = urlObj.pathname; const pathWithoutHash = pathname.split('#')[0]; const pathWithoutTrailingSlash = pathWithoutHash.replace(/\/$/, ''); // 获取当前任务组的配置 const taskGroup = this.getTaskGroupByUrl(url); if (!taskGroup) return false; // 1. 首先检查是否匹配 excludePatterns if (taskGroup.excludePatterns && taskGroup.excludePatterns.length > 0) { for (const pattern of taskGroup.excludePatterns) { if (pattern.test(pathWithoutTrailingSlash)) { return false; } } } // 2. 检查是否匹配 includePatterns if (taskGroup.includePatterns && taskGroup.includePatterns.length > 0) { let matched = false; for (const pattern of taskGroup.includePatterns) { // 将通配符模式转换为正则表达式 const regexPattern = pattern .replace(/\*/g, '.*') // 将*转换为.* .replace(/\?/g, '.') // 将?转换为. .replace(/\[/g, '[') // 保持[不变 .replace(/\]/g, ']'); // 保持]不变 // 确保严格匹配整个路径 const regex = new RegExp(regexPattern); if (regex.test(pathWithoutTrailingSlash)) { matched = true; break; } } // 如果不匹配includePatterns,直接返回false if (!matched) return false; } // 3. 检查完includePatterns和excludePatterns后,默认返回true // 这样可以由配置控制,而不是硬编码特定的规则 return true; } catch (error) { console.error('检查链接时出错:', error); return false; } } /** * 处理组件页面 * @param {string} url - 要处理的URL * @param {object} taskGroup - 任务组 */ async processComponentPage(url, taskGroup) { let page = null; try { console.log(`\n[处理] ${taskGroup.name} - ${url}`); // 检查是否已经爬取过 if (taskGroup.pages.has(url)) { console.log(`[跳过] ${url} 已存在,跳过处理`); return; } page = await this.browserManager.getPage(url); // 使用更好的等待策略 await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }).catch(() => { console.log(`[警告] 页面加载超时,尝试继续处理: ${url}`); }); // 确保页面内容加载,滚动页面触发懒加载 await this.scrollPageToLoadLazyContent(page); // 直接从DOM中提取内容 const pageData = await page.evaluate(() => { // 清理无关内容 const cleaned = document.body.cloneNode(true); // 移除所有script、style和其他非文档内容标签 cleaned.querySelectorAll('script, style, link[rel="stylesheet"], iframe, nav, header, footer, [data-type="js"], [data-type="css"]').forEach(el => el.remove()); // 提取内容 const result = { title: '', description: '', props: [], events: [], examples: [], content: [] }; // 提取标题 const titleEl = cleaned.querySelector('h1') || cleaned.querySelector('[class*="title" i]') || cleaned.querySelector('[id*="title" i]'); if (titleEl) { result.title = titleEl.textContent.trim(); } // 提取描述 const descEl = cleaned.querySelector('p') || cleaned.querySelector('[class*="desc" i]') || cleaned.querySelector('[class*="summary" i]'); if (descEl) { result.description = descEl.textContent.trim(); } // 提取表格内容 cleaned.querySelectorAll('table').forEach(table => { const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim().toLowerCase()); const rows = Array.from(table.querySelectorAll('tr')).slice(1); if (headers.some(h => h.includes('prop') || h.includes('参数') || h.includes('属性'))) { rows.forEach(row => { const cells = Array.from(row.querySelectorAll('td')).map(td => td.textContent.trim()); if (cells.length >= 2) { result.props.push({ name: cells[0], type: cells[1], description: cells[cells.length - 1] }); } }); } else if (headers.some(h => h.includes('event') || h.includes('事件'))) { rows.forEach(row => { const cells = Array.from(row.querySelectorAll('td')).map(td => td.textContent.trim()); if (cells.length >= 2) { result.events.push({ name: cells[0], description: cells[cells.length - 1] }); } }); } }); // 提取代码示例 - 仅保留在内容区域的代码示例 const contentAreas = cleaned.querySelectorAll('.markdown-body, .doc-content, article, main, .content, [class*="example" i]'); if (contentAreas.length > 0) { contentAreas.forEach(area => { area.querySelectorAll('pre, code, [class*="code" i], [class*="example" i]').forEach(block => { const code = block.textContent.trim(); if (code) { const language = block.className.match(/language[-:](\w+)/)?.[1] || block.getAttribute('data-lang') || 'javascript'; result.examples.push({ code, language }); } }); }); } else { // 如果没有找到内容区域,谨慎提取代码示例 cleaned.querySelectorAll('pre, code, [class*="code" i], [class*="example" i]').forEach(block => { // 检查代码示例是否在导航、侧边栏等非内容区域 const isInNonContentArea = block.closest('nav, .sidebar, .navigation, .menu'); if (!isInNonContentArea) { const code = block.textContent.trim(); if (code) { const language = block.className.match(/language[-:](\w+)/)?.[1] || block.getAttribute('data-lang') || 'javascript'; result.examples.push({ code, language }); } } }); } // 提取结构化内容 const contentWalker = document.createTreeWalker( cleaned, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { const validTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI']; return validTags.includes(node.tagName) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } } ); let currentNode; while (currentNode = contentWalker.nextNode()) { const text = currentNode.textContent.trim(); if (text) { // 过滤掉可能的JS/CSS代码片段 const filteredText = text .replace(/{[\s\S]*?}/g, '') // 移除花括号内的内容 .replace(/function\s*\([\s\S]*?\)\s*{[\s\S]*?}/g, '') // 移除函数定义 .replace(/var\s+\w+\s*=.*;/g, '') // 移除变量定义 .replace(/const\s+\w+\s*=.*;/g, '') // 移除常量定义 .replace(/let\s+\w+\s*=.*;/g, '') // 移除let声明 .replace(/import\s+.*;/g, '') // 移除import语句 .replace(/export\s+.*;/g, '') // 移除export语句 .replace(/\/\*[\s\S]*?\*\//g, '') // 移除注释块 .replace(/\/\/.*\n/g, '\n') // 移除行注释 .replace(/<path[^>]*d=\\?"[^"]*\\?"[^>]*>/g, '') // 移除SVG路径数据 .replace(/d=\\?"[mMlLhHvVcCsSqQtTaAzZ0-9\s,.-]+\\?"/g, '') // 移除SVG路径描述数据 .trim(); if (filteredText) { result.content.push({ type: currentNode.tagName.toLowerCase(), text: filteredText }); } } } return result; }); // 检查内容是否足够 const contentLength = pageData.content.reduce((total, item) => total + item.text.length, 0); if (contentLength < 500) { console.log(`[动态内容] 组件页面内容较少(${contentLength}字符),等待更多内容加载后重新抓取`); // 等待3秒后重新抓取 await new Promise(resolve => setTimeout(resolve, 3000)); // 重新抓取内容 const newPageData = await page.evaluate(() => { // 清理无关内容 const cleaned = document.body.cloneNode(true); // 移除所有script、style和其他非文档内容标签 cleaned.querySelectorAll('script, style, link[rel="stylesheet"], iframe, nav, header, footer, [data-type="js"], [data-type="css"]').forEach(el => el.remove()); // 提取内容 const result = { title: '', description: '', props: [], events: [], examples: [], content: [] }; // 提取标题 const titleEl = cleaned.querySelector('h1') || cleaned.querySelector('[class*="title" i]') || cleaned.querySelector('[id*="title" i]'); if (titleEl) { result.title = titleEl.textContent.trim(); } // 提取描述 const descEl = cleaned.querySelector('p') || cleaned.querySelector('[class*="desc" i]') || cleaned.querySelector('[class*="summary" i]'); if (descEl) { result.description = descEl.textContent.trim(); } // 提取表格内容 cleaned.querySelectorAll('table').forEach(table => { const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim().toLowerCase()); const rows = Array.from(table.querySelectorAll('tr')).slice(1); if (headers.some(h => h.includes('prop') || h.includes('参数') || h.includes('属性'))) { rows.forEach(row => { const cells = Array.from(row.querySelectorAll('td')).map(td => td.textContent.trim()); if (cells.length >= 2) { result.props.push({ name: cells[0], type: cells[1], description: cells[cells.length - 1] }); } }); } else if (headers.some(h => h.includes('event') || h.includes('事件'))) { rows.forEach(row => { const cells = Array.from(row.querySelectorAll('td')).map(td => td.textContent.trim()); if (cells.length >= 2) { result.events.push({ name: cells[0], description: cells[cells.length - 1] }); } }); } }); // 提取代码示例 - 仅保留在内容区域的代码示例 const contentAreas = cleaned.querySelectorAll('.markdown-body, .doc-content, article, main, .content, [class*="example" i]'); if (contentAreas.length > 0) { contentAreas.forEach(area => { area.querySelectorAll('pre, code, [class*="code" i], [class*="example" i]').forEach(block => { const code = block.textContent.trim(); if (code) { const language = block.className.match(/language[-:](\w+)/)?.[1] || block.getAttribute('data-lang') || 'javascript'; result.examples.push({ code, language }); } }); }); } else { // 如果没有找到内容区域,谨慎提取代码示例 cleaned.querySelectorAll('pre, code, [class*="code" i], [class*="example" i]').forEach(block => { // 检查代码示例是否在导航、侧边栏等非内容区域 const isInNonContentArea = block.closest('nav, .sidebar, .navigation, .menu'); if (!isInNonContentArea) { const code = block.textContent.trim(); if (code) { const language = block.className.match(/language[-:](\w+)/)?.[1] || block.getAttribute('data-lang') || 'javascript'; result.examples.push({ code, language }); } } }); } // 提取结构化内容 const contentWalker = document.createTreeWalker( cleaned, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { const validTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI']; return validTags.includes(node.tagName) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } } ); let currentNode; while (currentNode = contentWalker.nextNode()) { const text = currentNode.textContent.trim(); if (text) { // 过滤掉可能的JS/CSS代码片段 const filteredText = text .replace(/{[\s\S]*?}/g, '') // 移除花括号内的内容 .replace(/function\s*\([\s\S]*?\)\s*{[\s\S]*?}/g, '') // 移除函数定义 .replace(/var\s+\w+\s*=.*;/g, '') // 移除变量定义 .replace(/const\s+\w+\s*=.*;/g, '') // 移除常量定义 .replace(/let\s+\w+\s*=.*;/g, '') // 移除let声明 .replace(/import\s+.*;/g, '') // 移除import语句 .replace(/export\s+.*;/g, '') // 移除export语句 .replace(/\/\*[\s\S]*?\*\//g, '') // 移除注释块 .replace(/\/\/.*\n/g, '\n') // 移除行注释 .replace(/<path[^>]*d=\\?"[^"]*\\?"[^>]*>/g, '') // 移除SVG路径数据 .replace(/d=\\?"[mMlLhHvVcCsSqQtTaAzZ0-9\s,.-]+\\?"/g, '') // 移除SVG路径描述数据 .trim(); if (filteredText) { result.content.push({ type: currentNode.tagName.toLowerCase(), text: filteredText }); } } } return result; }).catch(error => { console.warn(`[警告] 重新提取组件页面内容失败: ${error.message}`); return pageData; // 如果重新提取失败,返回原始数据 }); // 计算新的内容长度 const newContentLength = newPageData.content.reduce((total, item) => total + item.text.length, 0); // 如果新内容比旧内容长20%以上,使用新内容 if (newContentLength > contentLength * 1.2) { console.log(`[动态内容] 组件页面重新抓取成功!内容从${contentLength}字符增加到${newContentLength}字符`); // 使用新抓取的数据 Object.assign(pageData, newPageData); } else { console.log(`[动态内容] 组件页面重新抓取未获得明显改进,继续使用原始内容`); } } // 保存页面数据 taskGroup.pages.set(url, { title: pageData.title, content: pageData.content }); // 每爬取一个页面就保存一次 await this.savePage(taskGroup.name, url, { title: pageData.title, content: pageData.content }); console.log(`[完成] ${taskGroup.name} - ${url}`); } catch (error) { console.error(`[错误] 处理页面失败: ${url}`, error); } finally { if (page) { await page.close().catch(() => {}); console.log(`[关闭] 已关闭页面: ${url}`); } } } /** * 等待动态内容加载完成 * @param {Page} page - Puppeteer页面对象 * @param {number} timeout - 超时时间(毫秒),默认为10000ms * @returns {Promise<boolean>} 是否检测到动态内容并等待完成 */ async waitForDynamicContent(page, timeout = 10000) { console.log(`[动态内容] 开始检测动态加载内容...`); try { // 1. 检测常见的加载指示器 const loadingSelectors = [ '.loading', '#loading', '[class*="loading"]', '.spinner', '.loader', '.preloader', '[aria-busy="true"]', '[data-loading="true"]' ]; // 2. 检测页面中是否存在加载指示器 const hasLoadingIndicator = await page.evaluate((selectors) => { return selectors.some(selector => document.querySelector(selector) !== null); }, loadingSelectors); if (hasLoadingIndicator) { console.log(`[动态内容] 检测到加载指示器,等待其消失...`); // 等待所有加载指示器消失 for (const selector of loadingSelectors) { await page.waitForFunction( (sel) => !document.querySelector(sel) || document.querySelector(sel).style.display === 'none' || document.querySelector(sel).style.visibility === 'hidden', { timeout }, selector ).catch(() => {}); // 忽略超时错误 } console.log(`[动态内容] 加载指示器已消失`); } // 3. 检测DOM的稳定性 let domStable = false; let initialNodeCount = -1; let stableCount = 0; console.log(`[动态内容] 监测DOM稳定性...`); const startTime = Date.now(); while (!domStable && (Date.now() - startTime < timeout)) { // 获取当前DOM节点数量 const currentNodeCount = await page.evaluate(() => document.querySelectorAll('*').length); if (initialNodeCount === -1) { // 第一次检查 initialNodeCount = currentNodeCount; } else if (currentNodeCount === initialNodeCount) { // DOM节点数量没有变化 stableCount++; if (stableCount >= 3) { // 连续3次检查DOM节点数量不变,认为稳定 domStable = true; console.log(`[动态内容] DOM已稳定,节点数量: ${currentNodeCount}`); } } else { // DOM节点数量有变化,重置计数 console.log(`[动态内容] DOM变化中,节点数量从${initialNodeCount}变为${currentNodeCount}`); initialNodeCount = currentNodeCount; stableCount = 0; } // 等待一小段时间再次检查 await new Promise(resolve => setTimeout(resolve, 500)); } // 4. 等待网络请求完成 await page.waitForFunction(() => { return document.readyState === 'complete'; }, { timeout: 5000 }).catch(() => { console.log(`[动态内容] 等待document.readyState超时`); }); // 5. 额外等待一小段时间,确保所有内容都已渲染 await new Promise(resolve => setTimeout(resolve, 1000)); // 6. 尝试滚动页面以触发懒加载 await this.scrollPageToLoadLazyContent(page); console.log(`[动态内容] 动态内容加载检测完成`); return true; } catch (error) { console.warn(`[动态内容] 等待动态内容时出错: ${error.message}`); return false; } } /** * 滚动页面以触发懒加载内容 * @param {Page} page - Puppeteer页面对象 */ async scrollPageToLoadLazyContent(page) { try { // 获取页面高度 const pageHeight = await page.evaluate(() => document.body.scrollHeight); const viewportHeight = await page.evaluate(() => window.innerHeight); console.log(`[滚动] 开始滚动页面以加载懒加载内容,页面高度: ${pageHeight}px`); // 使用一个更高效的方法来触发所有懒加载内容 await page.evaluate(() => { return new Promise((resolve) => { let totalHeight = 0; const distance = 300; const maxScrolls = 20; // 限制最大滚动次数 let scrollCount = 0; const timer = setInterval(() => { // 获取当前文档高度,可能在滚动过程中发生变化 const scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; scrollCount++; // 如果已经滚动到底部或者达到最大滚动次数,停止滚动 if (totalHeight >= scrollHeight || scrollCount >= maxScrolls) { clearInterval(timer); window.scrollTo(0, 0); // 滚回顶部 resolve(); } }, 200); }); }).catch(error => { console.warn(`[滚动] 页面滚动脚本执行出错: ${error.message}`); }); console.log(`[滚动] 页面滚动完成`); } catch (error) { console.warn(`[滚动] 滚动页面时出错: ${error.message}`); } } /** * 确保请求拦截正确启用 * @param {Page} page - Puppeteer页面对象 * @returns {Promise<boolean>} 是否成功启用请求拦截 */ async ensureRequestInterception(page) { try { // 检查当前拦截状态 const interceptEnabled = await page.evaluate(() => { return window._puppeteer_request_interception || false; }).catch(() => false); if (interceptEnabled) { // 已经启用,无需再次启用 return true; } // 确保没有请求监听器 try { page.removeAllListeners('request'); console.log(`[API] 已移除所有请求监听器,重新启用拦截`); } catch (e) { // 忽略错误 } // 重新启用请求拦截 await page.setRequestInterception(true); // 设置标记 await page.evaluate(() => { window._puppeteer_request_interception = true; }); console.log(`[API] 已成功启用请求拦截`); return true; } catch (error) { console.log(`[API] 启用请求拦截失败: ${error.message}`); return false; } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ruan11223344/McpDocServer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server