@lmcc-dev/mult-fetch-mcp-server 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -15
- package/README.zh.md +31 -0
- package/dist/src/client.js +118 -91
- package/dist/src/lib/fetchers/browser/BrowserFetcher.js +25 -10
- package/dist/src/lib/fetchers/browser/BrowserInstance.js +67 -64
- package/dist/src/lib/fetchers/common/BaseFetcher.js +106 -10
- package/dist/src/lib/fetchers/common/types.js +10 -1
- package/dist/src/lib/fetchers/common/utils.js +1 -1
- package/dist/src/lib/fetchers/node/HttpClient.js +41 -21
- package/dist/src/lib/fetchers/node/NodeFetcher.js +24 -8
- package/dist/src/lib/i18n/index.js +2 -2
- package/dist/src/lib/i18n/keys/client.js +1 -0
- package/dist/src/lib/i18n/keys/extractor.js +26 -0
- package/dist/src/lib/i18n/keys/fetcher.js +12 -1
- package/dist/src/lib/i18n/keys/index.js +1 -0
- package/dist/src/lib/i18n/keys/node.js +2 -0
- package/dist/src/lib/i18n/locales/en/client.js +1 -0
- package/dist/src/lib/i18n/locales/en/extractor.js +24 -0
- package/dist/src/lib/i18n/locales/en/fetcher.js +3 -1
- package/dist/src/lib/i18n/locales/en/index.js +2 -0
- package/dist/src/lib/i18n/locales/en/node.js +2 -0
- package/dist/src/lib/i18n/locales/zh/client.js +1 -0
- package/dist/src/lib/i18n/locales/zh/extractor.js +22 -0
- package/dist/src/lib/i18n/locales/zh/fetcher.js +13 -3
- package/dist/src/lib/i18n/locales/zh/index.js +2 -0
- package/dist/src/lib/i18n/locales/zh/node.js +2 -0
- package/dist/src/lib/i18n/logger.js +2 -2
- package/dist/src/lib/logger.js +38 -17
- package/dist/src/lib/server/browser.js +2 -2
- package/dist/src/lib/server/fetcher.js +0 -3
- package/dist/src/lib/server/index.js +2 -2
- package/dist/src/lib/server/prompts.js +4 -4
- package/dist/src/lib/server/tools.js +127 -354
- package/dist/src/lib/utils/ChunkManager.js +2 -2
- package/dist/src/lib/utils/ContentExtractor.js +141 -0
- package/dist/src/lib/utils/ContentProcessor.js +5 -11
- package/dist/src/lib/utils/ContentSizeManager.js +2 -2
- package/dist/src/lib/utils/ErrorHandler.js +1 -0
- package/dist/src/lib/utils/TemplateUtils.js +6 -2
- package/dist/tests/client.test.js +1 -1
- package/dist/tests/test-extract-single.js +389 -0
- package/dist/tests/utils/ContentExtractor.test.js +173 -0
- package/dist/tests/utils/ContentProcessor.test.js +136 -0
- package/dist/tests/utils/TemplateUtils.test.js +118 -0
- package/package.json +13 -3
- package/dist/i18n-test-report.json +0 -8
- package/dist/i18n-unused-keys-report.json +0 -8
- package/dist/src/lib/BrowserFetcher.js +0 -787
- package/dist/src/lib/NodeFetcher.js +0 -492
- package/dist/src/lib/i18n/keys.js +0 -529
- package/dist/src/test-i18n.js +0 -139
- package/dist/tests/BrowserFetcher.test.js +0 -951
- package/dist/tests/NodeFetcher.test.js +0 -263
- package/dist/tests/i18n-remove-unused-keys.js +0 -236
- package/dist/tests/i18n-test-report.json +0 -2004
- package/dist/tests/src/lib/i18n/index.js +0 -108
- package/dist/tests/src/lib/i18n/keys/base.js +0 -47
- package/dist/tests/src/lib/i18n/keys/browser.js +0 -93
- package/dist/tests/src/lib/i18n/keys/client.js +0 -70
- package/dist/tests/src/lib/i18n/keys/errors.js +0 -34
- package/dist/tests/src/lib/i18n/keys/fetcher.js +0 -84
- package/dist/tests/src/lib/i18n/keys/index.js +0 -31
- package/dist/tests/src/lib/i18n/keys/node.js +0 -56
- package/dist/tests/src/lib/i18n/keys/prompts.js +0 -82
- package/dist/tests/src/lib/i18n/keys/resources.js +0 -50
- package/dist/tests/src/lib/i18n/keys/server.js +0 -64
- package/dist/tests/src/lib/i18n/keys/tools.js +0 -34
- package/dist/tests/src/lib/i18n/locales/en/browser.js +0 -88
- package/dist/tests/src/lib/i18n/locales/en/client.js +0 -66
- package/dist/tests/src/lib/i18n/locales/en/errors.js +0 -28
- package/dist/tests/src/lib/i18n/locales/en/fetcher.js +0 -71
- package/dist/tests/src/lib/i18n/locales/en/index.js +0 -29
- package/dist/tests/src/lib/i18n/locales/en/node.js +0 -51
- package/dist/tests/src/lib/i18n/locales/en/prompts.js +0 -52
- package/dist/tests/src/lib/i18n/locales/en/resources.js +0 -50
- package/dist/tests/src/lib/i18n/locales/en/server.js +0 -56
- package/dist/tests/src/lib/i18n/locales/en/tools.js +0 -28
- package/dist/tests/src/lib/i18n/locales/zh/browser.js +0 -87
- package/dist/tests/src/lib/i18n/locales/zh/client.js +0 -66
- package/dist/tests/src/lib/i18n/locales/zh/errors.js +0 -28
- package/dist/tests/src/lib/i18n/locales/zh/fetcher.js +0 -71
- package/dist/tests/src/lib/i18n/locales/zh/index.js +0 -29
- package/dist/tests/src/lib/i18n/locales/zh/node.js +0 -51
- package/dist/tests/src/lib/i18n/locales/zh/prompts.js +0 -53
- package/dist/tests/src/lib/i18n/locales/zh/resources.js +0 -50
- package/dist/tests/src/lib/i18n/locales/zh/server.js +0 -57
- package/dist/tests/src/lib/i18n/locales/zh/tools.js +0 -28
- package/dist/tests/src/lib/i18n/logger.js +0 -114
- package/dist/tests/src/lib/logger.js +0 -181
- package/dist/tests/tests/test-i18n.js +0 -588
- package/dist/vitest.config.js +0 -29
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Author: Martin <lmccc.dev@gmail.com>
|
|
3
|
+
* Co-Author: AI Assistant (Claude)
|
|
4
|
+
* Description: This code was collaboratively developed by Martin and AI Assistant.
|
|
5
|
+
*/
|
|
6
|
+
import { Readability } from '@mozilla/readability';
|
|
7
|
+
import { JSDOM } from 'jsdom';
|
|
8
|
+
import { log, COMPONENTS } from '../logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* 内容提取器类 (Content extractor class)
|
|
11
|
+
* 提供智能内容提取功能,基于Mozilla的Readability库 (Provides intelligent content extraction based on Mozilla's Readability library)
|
|
12
|
+
*/
|
|
13
|
+
export class ContentExtractor {
|
|
14
|
+
/**
|
|
15
|
+
* 提取HTML中的主要内容 (Extract main content from HTML)
|
|
16
|
+
* @param html HTML内容 (HTML content)
|
|
17
|
+
* @param url 页面URL,用于处理相对路径 (Page URL, used for handling relative paths)
|
|
18
|
+
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
19
|
+
* @returns 提取的内容对象,包括标题、内容、文本等 (Extracted content object, including title, content, text, etc.)
|
|
20
|
+
*/
|
|
21
|
+
static extractContent(html, url, debug = false) {
|
|
22
|
+
log('extractor.creating_jsdom', debug, { url }, COMPONENTS.EXTRACTOR);
|
|
23
|
+
try {
|
|
24
|
+
// 创建JSDOM文档 (Create JSDOM document)
|
|
25
|
+
const doc = new JSDOM(html, { url });
|
|
26
|
+
// 检查页面是否适合进行可读性提取 (Check if the page is suitable for readability extraction)
|
|
27
|
+
const isReaderable = this.isProbablyReaderable(doc.window.document, debug);
|
|
28
|
+
// 如果页面不适合提取,返回空结果 (If the page is not suitable for extraction, return empty result)
|
|
29
|
+
if (!isReaderable && debug) {
|
|
30
|
+
log('extractor.page_not_readerable', true, { url }, COMPONENTS.EXTRACTOR);
|
|
31
|
+
}
|
|
32
|
+
// 即使页面可能不适合提取,我们也尝试进行提取 (Even if the page may not be suitable for extraction, we try to extract anyway)
|
|
33
|
+
// 创建Readability解析器 (Create Readability parser)
|
|
34
|
+
log('extractor.creating_reader', debug, {}, COMPONENTS.EXTRACTOR);
|
|
35
|
+
const reader = new Readability(doc.window.document);
|
|
36
|
+
// 解析内容 (Parse content)
|
|
37
|
+
log('extractor.parsing_content', debug, {}, COMPONENTS.EXTRACTOR);
|
|
38
|
+
const article = reader.parse();
|
|
39
|
+
// 如果解析失败,返回空结果 (If parsing failed, return empty result)
|
|
40
|
+
if (!article) {
|
|
41
|
+
log('extractor.parsing_failed', debug, { url }, COMPONENTS.EXTRACTOR);
|
|
42
|
+
return {
|
|
43
|
+
title: null,
|
|
44
|
+
content: null,
|
|
45
|
+
textContent: null,
|
|
46
|
+
excerpt: null,
|
|
47
|
+
byline: null,
|
|
48
|
+
siteName: null,
|
|
49
|
+
length: 0,
|
|
50
|
+
isReaderable
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
log('extractor.parsing_success', debug, {
|
|
54
|
+
title: article.title,
|
|
55
|
+
contentLength: article.content?.length || 0,
|
|
56
|
+
textLength: article.textContent?.length || 0
|
|
57
|
+
}, COMPONENTS.EXTRACTOR);
|
|
58
|
+
// 返回解析结果 (Return parsing result)
|
|
59
|
+
return {
|
|
60
|
+
title: article.title,
|
|
61
|
+
content: article.content,
|
|
62
|
+
textContent: article.textContent,
|
|
63
|
+
excerpt: article.excerpt,
|
|
64
|
+
byline: article.byline,
|
|
65
|
+
siteName: article.siteName,
|
|
66
|
+
length: article.length,
|
|
67
|
+
isReaderable
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// 处理错误 (Handle error)
|
|
72
|
+
log('extractor.extraction_error', true, {
|
|
73
|
+
error: error instanceof Error ? error.message : String(error),
|
|
74
|
+
url
|
|
75
|
+
}, COMPONENTS.EXTRACTOR);
|
|
76
|
+
return {
|
|
77
|
+
title: null,
|
|
78
|
+
content: null,
|
|
79
|
+
textContent: null,
|
|
80
|
+
excerpt: null,
|
|
81
|
+
byline: null,
|
|
82
|
+
siteName: null,
|
|
83
|
+
length: 0,
|
|
84
|
+
isReaderable: false
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 检查页面是否适合进行可读性提取 (Check if the page is suitable for readability extraction)
|
|
90
|
+
* @param document DOM文档 (DOM document)
|
|
91
|
+
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
92
|
+
* @returns 是否适合提取 (Whether it is suitable for extraction)
|
|
93
|
+
*/
|
|
94
|
+
static isProbablyReaderable(document, debug = false) {
|
|
95
|
+
// 这些是常见的无法提取内容的页面类型 (These are common page types that cannot extract content)
|
|
96
|
+
const unlikelyPageTypes = [
|
|
97
|
+
/login/i, /signup/i, /register/i, /404/i, /403/i, /error/i, /captcha/i,
|
|
98
|
+
/password/i, /forgot/i, /reset/i, /signin/i, /signout/i, /logout/i,
|
|
99
|
+
/search/i, /contact/i, /about/i, /faq/i, /help/i, /support/i,
|
|
100
|
+
/dashboard/i, /admin/i, /profile/i, /account/i, /settings/i,
|
|
101
|
+
/cart/i, /checkout/i, /basket/i, /purchase/i, /payment/i,
|
|
102
|
+
/calculator/i, /converter/i, /translator/i
|
|
103
|
+
];
|
|
104
|
+
// 检查URL和标题是否包含不太可能包含文章的关键词 (Check if URL and title contain keywords that are unlikely to contain articles)
|
|
105
|
+
const url = document.location?.href || '';
|
|
106
|
+
const title = document.title || '';
|
|
107
|
+
for (const pattern of unlikelyPageTypes) {
|
|
108
|
+
if (pattern.test(url) || pattern.test(title)) {
|
|
109
|
+
if (debug) {
|
|
110
|
+
log('extractor.unlikely_page_type', debug, { pattern: pattern.toString(), url, title }, COMPONENTS.EXTRACTOR);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 检查是否有文章相关元素 (Check if there are article-related elements)
|
|
116
|
+
const hasArticleElements = !!document.querySelector('article') ||
|
|
117
|
+
!!document.querySelector('[role="article"]') ||
|
|
118
|
+
!!document.querySelector('[itemprop="articleBody"]') ||
|
|
119
|
+
!!document.querySelector('.post-content') ||
|
|
120
|
+
!!document.querySelector('.article-content') ||
|
|
121
|
+
!!document.querySelector('.entry-content');
|
|
122
|
+
if (hasArticleElements) {
|
|
123
|
+
if (debug) {
|
|
124
|
+
log('extractor.has_article_elements', debug, {}, COMPONENTS.EXTRACTOR);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
// 检查是否有足够的段落 (Check if there are enough paragraphs)
|
|
129
|
+
const paragraphs = document.querySelectorAll('p');
|
|
130
|
+
if (paragraphs.length >= 5) {
|
|
131
|
+
if (debug) {
|
|
132
|
+
log('extractor.has_enough_paragraphs', debug, { count: paragraphs.length }, COMPONENTS.EXTRACTOR);
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (debug) {
|
|
137
|
+
log('extractor.not_enough_content', debug, { paragraphs: paragraphs.length }, COMPONENTS.EXTRACTOR);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -16,10 +16,9 @@ export class ContentProcessor {
|
|
|
16
16
|
* 将HTML转换为Markdown (Convert HTML to Markdown)
|
|
17
17
|
* @param html HTML内容 (HTML content)
|
|
18
18
|
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
19
|
-
* @param component 组件名称 (Component name for logging) - 仅用于向下兼容,实际会使用PROCESSOR组件
|
|
20
19
|
* @returns Markdown内容 (Markdown content)
|
|
21
20
|
*/
|
|
22
|
-
static htmlToMarkdown(html, debug
|
|
21
|
+
static htmlToMarkdown(html, debug) {
|
|
23
22
|
// 始终使用COMPONENTS.PROCESSOR作为组件标识符 (Always use COMPONENTS.PROCESSOR as component identifier)
|
|
24
23
|
log('processor.creatingTurndown', debug, {}, COMPONENTS.PROCESSOR);
|
|
25
24
|
const turndownService = new TurndownService({
|
|
@@ -45,10 +44,9 @@ export class ContentProcessor {
|
|
|
45
44
|
* 将HTML转换为纯文本 (Convert HTML to plain text)
|
|
46
45
|
* @param html HTML内容 (HTML content)
|
|
47
46
|
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
48
|
-
* @param component 组件名称 (Component name for logging) - 仅用于向下兼容,实际会使用PROCESSOR组件
|
|
49
47
|
* @returns 纯文本内容 (Plain text content)
|
|
50
48
|
*/
|
|
51
|
-
static htmlToText(html, debug
|
|
49
|
+
static htmlToText(html, debug) {
|
|
52
50
|
// 始终使用COMPONENTS.PROCESSOR作为组件标识符 (Always use COMPONENTS.PROCESSOR as component identifier)
|
|
53
51
|
log('processor.creatingHtmlToText', debug, {}, COMPONENTS.PROCESSOR);
|
|
54
52
|
// 配置html-to-text选项 (Configure html-to-text options)
|
|
@@ -70,10 +68,9 @@ export class ContentProcessor {
|
|
|
70
68
|
* 解析JSON字符串 (Parse JSON string)
|
|
71
69
|
* @param text JSON字符串 (JSON string)
|
|
72
70
|
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
73
|
-
* @param component 组件名称 (Component name for logging) - 仅用于向下兼容,实际会使用PROCESSOR组件
|
|
74
71
|
* @returns 解析结果 (Parse result - success or error with message)
|
|
75
72
|
*/
|
|
76
|
-
static parseJson(text, debug
|
|
73
|
+
static parseJson(text, debug) {
|
|
77
74
|
log('processor.parsingJson', debug, {}, COMPONENTS.PROCESSOR);
|
|
78
75
|
try {
|
|
79
76
|
const parsed = JSON.parse(text);
|
|
@@ -95,10 +92,9 @@ export class ContentProcessor {
|
|
|
95
92
|
* 处理文本内容,确保是UTF-8编码 (Process text content, ensure it's UTF-8 encoded)
|
|
96
93
|
* @param text 文本内容 (Text content)
|
|
97
94
|
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
98
|
-
* @param component 组件名称 (Component name for logging) - 仅用于向下兼容,实际会使用PROCESSOR组件
|
|
99
95
|
* @returns 处理后的文本 (Processed text)
|
|
100
96
|
*/
|
|
101
|
-
static processTextContent(text, debug
|
|
97
|
+
static processTextContent(text, debug) {
|
|
102
98
|
log('processor.processingText', debug, { length: text.length }, COMPONENTS.PROCESSOR);
|
|
103
99
|
// 这里可以添加文本处理逻辑,如编码转换、去除特殊字符等
|
|
104
100
|
// (Add text processing logic here, such as encoding conversion, removing special characters, etc.)
|
|
@@ -109,10 +105,8 @@ export class ContentProcessor {
|
|
|
109
105
|
* @param content 内容 (Content)
|
|
110
106
|
* @param contentSizeLimit 内容大小限制 (Content size limit)
|
|
111
107
|
* @param debug 是否开启调试 (Whether to enable debugging)
|
|
112
|
-
* @param component 组件名称 (Component name for logging) - 仅用于向下兼容,实际会使用PROCESSOR组件
|
|
113
|
-
* @throws {ToolError} 如果内容太大且不允许分割 (If content is too large and splitting is not allowed)
|
|
114
108
|
*/
|
|
115
|
-
static validateContentSize(content, contentSizeLimit, debug
|
|
109
|
+
static validateContentSize(content, contentSizeLimit, debug) {
|
|
116
110
|
const contentSize = content.length;
|
|
117
111
|
if (contentSize > contentSizeLimit) {
|
|
118
112
|
log('processor.contentTooLarge', debug, {
|
|
@@ -89,10 +89,10 @@ export class ContentSizeManager {
|
|
|
89
89
|
* @param content 原始内容 (Original content)
|
|
90
90
|
* @param sizeLimit 每个片段的大小限制,单位为字节 (Size limit for each chunk in bytes)
|
|
91
91
|
* @param debug 是否启用调试模式 (Whether debug mode is enabled)
|
|
92
|
-
* @param
|
|
92
|
+
* @param _offset 当前偏移量,用于确定是首次请求还是后续请求 (Current offset, used to determine if it's initial or subsequent request)
|
|
93
93
|
* @returns 内容片段数组 (Array of content chunks)
|
|
94
94
|
*/
|
|
95
|
-
static splitContentIntoChunks(content, sizeLimit = this.DEFAULT_SIZE_LIMIT, debug = false,
|
|
95
|
+
static splitContentIntoChunks(content, sizeLimit = this.DEFAULT_SIZE_LIMIT, debug = false, _offset = 0) {
|
|
96
96
|
// 计算内容总字节大小 (Calculate total size of content in bytes)
|
|
97
97
|
const totalBytes = Buffer.byteLength(content, 'utf8');
|
|
98
98
|
// 使用TemplateUtils中的常量和方法生成示例模板以计算大小
|
|
@@ -44,6 +44,11 @@ export class TemplateUtils {
|
|
|
44
44
|
* @returns 格式化的提示文本 (Formatted prompt text)
|
|
45
45
|
*/
|
|
46
46
|
static generateSizeBasedChunkPrompt(fetchedBytes, totalBytes, chunkId, remainingBytes, estimatedRequests, currentSizeLimit, isFirstRequest = true) {
|
|
47
|
+
// 如果剩余字节数为0,使用完成提示而不是继续获取的提示
|
|
48
|
+
// (If remaining bytes is 0, use completion prompt instead of continuation prompt)
|
|
49
|
+
if (remainingBytes <= 0) {
|
|
50
|
+
return this.generateSizeBasedLastChunkPrompt(fetchedBytes, totalBytes, isFirstRequest);
|
|
51
|
+
}
|
|
47
52
|
const prefix = isFirstRequest ? 'Content is too long and has been split. ' : '';
|
|
48
53
|
const fetchedPercent = Math.round((fetchedBytes / totalBytes) * 100);
|
|
49
54
|
return `\n\n${TemplateUtils.SYSTEM_NOTE.START}\n${prefix}You've retrieved ${fetchedBytes.toLocaleString()} bytes (${fetchedPercent}% of total ${totalBytes.toLocaleString()} bytes). ${remainingBytes.toLocaleString()} bytes remaining. With current contentSizeLimit=${currentSizeLimit.toLocaleString()}, approximately ${estimatedRequests} more requests needed to retrieve all content. To continue, use the same tool function with parameters chunkId="${chunkId}" and startCursor=${fetchedBytes}.\n${TemplateUtils.SYSTEM_NOTE.END}`;
|
|
@@ -57,8 +62,7 @@ export class TemplateUtils {
|
|
|
57
62
|
*/
|
|
58
63
|
static generateSizeBasedLastChunkPrompt(fetchedBytes, totalBytes, isFirstRequest = true) {
|
|
59
64
|
const prefix = isFirstRequest ? 'Content is too long and has been split. ' : '';
|
|
60
|
-
|
|
61
|
-
return `\n\n${TemplateUtils.SYSTEM_NOTE.START}\n${prefix}You've retrieved ${fetchedBytes.toLocaleString()} bytes (100% of total ${totalBytes.toLocaleString()} bytes).\nThis is the last part of the content.\n${TemplateUtils.SYSTEM_NOTE.END}`;
|
|
65
|
+
return `\n\n${TemplateUtils.SYSTEM_NOTE.START}\n${prefix}You've retrieved ${fetchedBytes.toLocaleString()} bytes (100% of total ${totalBytes.toLocaleString()} bytes).\nThis is the last part of the content. No further requests needed.\n${TemplateUtils.SYSTEM_NOTE.END}`;
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
64
68
|
* 检查内容是否已包含系统提示 (Check if content already contains system prompt)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author: Martin <lmccc.dev@gmail.com>
|
|
3
|
+
* Co-Author: AI Assistant (Claude)
|
|
4
|
+
* Description: This code was collaboratively developed by Martin and AI Assistant.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* 内容提取功能多格式测试脚本 (Content extraction feature multi-format test script)
|
|
8
|
+
* 测试不同格式和参数组合下的内容提取功能 (Test content extraction functionality under different formats and parameter combinations)
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
// 获取当前文件的目录路径 (Get the directory path of the current file)
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
// 获取项目根目录 (Get project root directory)
|
|
18
|
+
const rootDir = process.cwd();
|
|
19
|
+
// 测试URL - 不修改 (Test URL - Do not modify)
|
|
20
|
+
const TEST_URL = 'https://www.bbc.com/news/articles/ceqjd11l55wo';
|
|
21
|
+
// 测试方法列表 (Test method list)
|
|
22
|
+
const TEST_METHODS = [
|
|
23
|
+
'fetch_html',
|
|
24
|
+
'fetch_plaintext',
|
|
25
|
+
'fetch_markdown',
|
|
26
|
+
'fetch_txt',
|
|
27
|
+
'fetch_json'
|
|
28
|
+
];
|
|
29
|
+
// 测试参数组 (Test parameter groups)
|
|
30
|
+
const TEST_PARAMS = {
|
|
31
|
+
// 基础参数组 - 简化参数 (Base parameter group - Simplified parameters)
|
|
32
|
+
base: {
|
|
33
|
+
url: TEST_URL,
|
|
34
|
+
startCursor: 0,
|
|
35
|
+
extractContent: false
|
|
36
|
+
},
|
|
37
|
+
// 内容提取参数组 - 简化参数 (Content extraction parameter group - Simplified parameters)
|
|
38
|
+
extraction: {
|
|
39
|
+
url: TEST_URL,
|
|
40
|
+
startCursor: 0,
|
|
41
|
+
extractContent: true
|
|
42
|
+
},
|
|
43
|
+
// 无回退参数组 - 增加必要参数 (No fallback parameter group - Add necessary parameters)
|
|
44
|
+
noFallback: {
|
|
45
|
+
url: TEST_URL,
|
|
46
|
+
startCursor: 0,
|
|
47
|
+
extractContent: true,
|
|
48
|
+
fallbackToOriginal: false
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
// 创建存放测试结果的目录
|
|
52
|
+
const TEST_RESULTS_DIR = path.join(rootDir, '.test-results');
|
|
53
|
+
console.log(`[信息] 测试结果将保存到: ${TEST_RESULTS_DIR}`);
|
|
54
|
+
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
|
55
|
+
fs.mkdirSync(TEST_RESULTS_DIR);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 将参数对象转换为命令行参数数组 (Convert parameter object to command line argument array)
|
|
59
|
+
* @param params 参数对象 (Parameter object)
|
|
60
|
+
* @returns 命令行参数数组 (Command line argument array)
|
|
61
|
+
*/
|
|
62
|
+
function paramsToCommandArgs(params) {
|
|
63
|
+
return Object.entries(params).map(([key, value]) => {
|
|
64
|
+
// 处理null和undefined值 (Handle null and undefined values)
|
|
65
|
+
if (value === null || value === undefined) {
|
|
66
|
+
return `--${key}=null`;
|
|
67
|
+
}
|
|
68
|
+
// 布尔值直接转换为字符串 (Convert boolean directly to string)
|
|
69
|
+
if (typeof value === 'boolean') {
|
|
70
|
+
return `--${key}=${value}`;
|
|
71
|
+
}
|
|
72
|
+
// 其他类型保持原样 (Keep other types as is)
|
|
73
|
+
return `--${key}=${value}`;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 运行测试 (Run test)
|
|
78
|
+
* @param method 测试方法 (Test method)
|
|
79
|
+
* @param groupName 参数组名称 (Parameter group name)
|
|
80
|
+
* @returns 测试结果对象 (Test result object)
|
|
81
|
+
*/
|
|
82
|
+
async function runTest(method, groupName) {
|
|
83
|
+
const params = TEST_PARAMS[groupName];
|
|
84
|
+
console.log(`\n=== 内容提取功能测试 [${method}] [${groupName}] ===`);
|
|
85
|
+
console.log(`[URL] ${TEST_URL}`);
|
|
86
|
+
console.log(`[参数] ${JSON.stringify(params, null, 2)}`);
|
|
87
|
+
console.log();
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
// 使用shell方式运行命令,避免参数转义问题
|
|
90
|
+
const paramsJson = JSON.stringify(params);
|
|
91
|
+
const command = `node dist/src/client.js ${method} '${paramsJson}' --debug`;
|
|
92
|
+
console.log(`[调试] 执行命令: ${command}`);
|
|
93
|
+
// 使用shell选项启动子进程,传递整个命令字符串
|
|
94
|
+
const child = spawn(command, [], { shell: true });
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
// 收集输出 (Collect output)
|
|
98
|
+
child.stdout.on('data', (data) => {
|
|
99
|
+
stdout += data.toString();
|
|
100
|
+
});
|
|
101
|
+
child.stderr.on('data', (data) => {
|
|
102
|
+
stderr += data.toString();
|
|
103
|
+
// 不再将stderr输出视为错误,改为记录日志 (Don't treat stderr output as error, record as log instead)
|
|
104
|
+
console.log(`[日志] ${data.toString()}`);
|
|
105
|
+
});
|
|
106
|
+
// 处理完成 (Handle completion)
|
|
107
|
+
child.on('close', (code) => {
|
|
108
|
+
console.log(`[测试完成] 退出码: ${code}`);
|
|
109
|
+
try {
|
|
110
|
+
// 尝试从stdout中提取JSON对象
|
|
111
|
+
// 只取大括号开始到结束的部分
|
|
112
|
+
const jsonMatch = stdout.match(/\{[\s\S]*\}\s*$/);
|
|
113
|
+
if (!jsonMatch) {
|
|
114
|
+
// 如果没找到合适的JSON,记录输出并返回失败
|
|
115
|
+
console.error('[解析错误] 无法在输出中找到有效的JSON对象');
|
|
116
|
+
console.error('标准输出:');
|
|
117
|
+
console.error(stdout);
|
|
118
|
+
reject(new Error('无法在输出中找到有效的JSON对象'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const jsonString = jsonMatch[0];
|
|
122
|
+
const jsonData = JSON.parse(jsonString);
|
|
123
|
+
// 提取并处理响应内容
|
|
124
|
+
const content = jsonData && jsonData.content && jsonData.content[0] ? jsonData.content[0].text : '';
|
|
125
|
+
const contentLength = content ? content.length : 0;
|
|
126
|
+
// 保存测试结果
|
|
127
|
+
const testResult = {
|
|
128
|
+
method: method,
|
|
129
|
+
responseData: jsonData,
|
|
130
|
+
content: content,
|
|
131
|
+
contentLength: contentLength,
|
|
132
|
+
metadata: jsonData.metadata || null,
|
|
133
|
+
parameters: params,
|
|
134
|
+
groupName: groupName
|
|
135
|
+
};
|
|
136
|
+
// 保存结果到文件
|
|
137
|
+
const resultFile = path.join(TEST_RESULTS_DIR, `${method}-${groupName}-result.json`);
|
|
138
|
+
fs.writeFileSync(resultFile, JSON.stringify(testResult, null, 2));
|
|
139
|
+
// 显示结果摘要
|
|
140
|
+
if (!jsonData.isError) {
|
|
141
|
+
console.log(`[成功] 获取到内容 (总长度: ${contentLength} 字符)`);
|
|
142
|
+
if (jsonData.metadata) {
|
|
143
|
+
console.log(`[元数据] ${JSON.stringify(jsonData.metadata, null, 2)}`);
|
|
144
|
+
}
|
|
145
|
+
// 打印更简洁的内容预览,最多显示200个字符 (Print more concise content preview, up to 200 characters)
|
|
146
|
+
console.log('[内容预览]');
|
|
147
|
+
if (content) {
|
|
148
|
+
// 移除多余空格和换行,简化显示 (Remove excess whitespace and newlines for simplified display)
|
|
149
|
+
const previewText = content.replace(/\s+/g, ' ').trim();
|
|
150
|
+
console.log(previewText.substring(0, Math.min(200, previewText.length)) + '...');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log('(空内容)');
|
|
154
|
+
}
|
|
155
|
+
resolve(testResult);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// 使用响应中的错误消息 (Use error message from response)
|
|
159
|
+
const errorMessage = jsonData.content && jsonData.content[0] ? jsonData.content[0].text : '未知错误';
|
|
160
|
+
console.error(`[错误] ${errorMessage}`);
|
|
161
|
+
// 对于非回退测试的错误,我们也保存结果以便分析
|
|
162
|
+
if (groupName === 'noFallback') {
|
|
163
|
+
console.log('[预期错误] 由于禁用回退,预期会出现错误');
|
|
164
|
+
resolve(testResult);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
reject(new Error(errorMessage));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
console.error(`[解析错误] ${err.message}`);
|
|
173
|
+
console.error('标准输出:');
|
|
174
|
+
console.error(stdout);
|
|
175
|
+
reject(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 比较测试结果 (Compare test results)
|
|
182
|
+
* @param method 测试方法 (Test method)
|
|
183
|
+
* @param baseResult 基础参数测试结果 (Base parameter test result)
|
|
184
|
+
* @param extractionResult 内容提取参数测试结果 (Content extraction parameter test result)
|
|
185
|
+
*/
|
|
186
|
+
function compareResults(method, baseResult, extractionResult) {
|
|
187
|
+
console.log(`\n====== ${method} 参数效果对比结果 ======`);
|
|
188
|
+
const baseContent = baseResult.content;
|
|
189
|
+
const extractionContent = extractionResult.content;
|
|
190
|
+
// 比较文本长度
|
|
191
|
+
console.log(`[基础参数] 内容长度: ${baseResult.contentLength} 字符`);
|
|
192
|
+
console.log(`[提取参数] 内容长度: ${extractionResult.contentLength} 字符`);
|
|
193
|
+
const lengthDiff = extractionResult.contentLength - baseResult.contentLength;
|
|
194
|
+
const percentDiff = baseResult.contentLength > 0 ?
|
|
195
|
+
((Math.abs(lengthDiff) / baseResult.contentLength) * 100).toFixed(2) : '0.00';
|
|
196
|
+
if (lengthDiff > 0) {
|
|
197
|
+
console.log(`[对比结果] 内容提取后长度增加了 ${lengthDiff} 字符 (+${percentDiff}%)`);
|
|
198
|
+
}
|
|
199
|
+
else if (lengthDiff < 0) {
|
|
200
|
+
console.log(`[对比结果] 内容提取后长度减少了 ${Math.abs(lengthDiff)} 字符 (-${percentDiff}%)`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
console.log(`[对比结果] 内容长度没有变化`);
|
|
204
|
+
}
|
|
205
|
+
// 比较内容特征
|
|
206
|
+
console.log("\n[内容特征对比]");
|
|
207
|
+
const baseHasHtml = /<[^>]*>/.test(baseContent);
|
|
208
|
+
const extractionHasHtml = /<[^>]*>/.test(extractionContent);
|
|
209
|
+
console.log(`- 基础参数内容${baseHasHtml ? '包含' : '不包含'} HTML 标签`);
|
|
210
|
+
console.log(`- 提取参数内容${extractionHasHtml ? '包含' : '不包含'} HTML 标签`);
|
|
211
|
+
// 检查清晰度改进
|
|
212
|
+
const urlCount = (content) => {
|
|
213
|
+
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
214
|
+
return (content.match(urlRegex) || []).length;
|
|
215
|
+
};
|
|
216
|
+
const baseUrlCount = urlCount(baseContent);
|
|
217
|
+
const extractionUrlCount = urlCount(extractionContent);
|
|
218
|
+
console.log(`- 基础参数内容包含 ${baseUrlCount} 个URL链接`);
|
|
219
|
+
console.log(`- 提取参数内容包含 ${extractionUrlCount} 个URL链接`);
|
|
220
|
+
if (extractionResult.metadata) {
|
|
221
|
+
console.log("\n[元数据]");
|
|
222
|
+
console.log(JSON.stringify(extractionResult.metadata, null, 2));
|
|
223
|
+
}
|
|
224
|
+
// 内容预览对比
|
|
225
|
+
if (baseResult.contentLength > 0 && extractionResult.contentLength > 0) {
|
|
226
|
+
console.log("\n[内容预览对比]");
|
|
227
|
+
console.log("基础参数内容开头:");
|
|
228
|
+
console.log(baseContent.substring(0, Math.min(200, baseContent.length)) + "...");
|
|
229
|
+
console.log("\n提取参数内容开头:");
|
|
230
|
+
console.log(extractionContent.substring(0, Math.min(200, extractionContent.length)) + "...");
|
|
231
|
+
}
|
|
232
|
+
// 对比后输出总结
|
|
233
|
+
console.log("\n[总体评估]");
|
|
234
|
+
if (extractionHasHtml && !baseHasHtml) {
|
|
235
|
+
console.log("- 内容提取优化了HTML标签处理,保留了更多格式信息");
|
|
236
|
+
}
|
|
237
|
+
else if (!extractionHasHtml && baseHasHtml) {
|
|
238
|
+
console.log("- 内容提取移除了HTML标签,简化了内容");
|
|
239
|
+
}
|
|
240
|
+
if (extractionUrlCount < baseUrlCount) {
|
|
241
|
+
console.log("- 内容提取减少了URL链接,提供了更简洁的阅读体验");
|
|
242
|
+
}
|
|
243
|
+
else if (extractionUrlCount > baseUrlCount) {
|
|
244
|
+
console.log("- 内容提取保留了更多URL链接,信息更完整");
|
|
245
|
+
}
|
|
246
|
+
if (extractionResult.metadata) {
|
|
247
|
+
console.log("- 内容提取成功获取了元数据,提供了额外的页面信息");
|
|
248
|
+
}
|
|
249
|
+
const effectivenessSummary = lengthDiff !== 0 ||
|
|
250
|
+
baseHasHtml !== extractionHasHtml ||
|
|
251
|
+
baseUrlCount !== extractionUrlCount ||
|
|
252
|
+
extractionResult.metadata ?
|
|
253
|
+
"内容提取功能对此网页有明显效果" :
|
|
254
|
+
"内容提取功能对此网页没有明显效果";
|
|
255
|
+
console.log(`- 整体评估: ${effectivenessSummary}`);
|
|
256
|
+
console.log("\n====== 对比结束 ======");
|
|
257
|
+
// 保存比较结果
|
|
258
|
+
const comparisonResult = {
|
|
259
|
+
method: method,
|
|
260
|
+
baseResult: baseResult,
|
|
261
|
+
extractionResult: extractionResult,
|
|
262
|
+
comparison: {
|
|
263
|
+
lengthDiff: lengthDiff,
|
|
264
|
+
percentDiff: percentDiff,
|
|
265
|
+
baseHasHtml: baseHasHtml,
|
|
266
|
+
extractionHasHtml: extractionHasHtml,
|
|
267
|
+
baseUrlCount: baseUrlCount,
|
|
268
|
+
extractionUrlCount: extractionUrlCount,
|
|
269
|
+
effectivenessSummary: effectivenessSummary
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const comparisonFile = path.join(TEST_RESULTS_DIR, `${method}-comparison-result.json`);
|
|
273
|
+
fs.writeFileSync(comparisonFile, JSON.stringify(comparisonResult, null, 2));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 生成测试结果摘要 (Generate test result summary)
|
|
277
|
+
* @param results 所有测试结果 (All test results)
|
|
278
|
+
*/
|
|
279
|
+
function generateSummary(results) {
|
|
280
|
+
const summaryFile = path.join(TEST_RESULTS_DIR, 'test-summary.json');
|
|
281
|
+
fs.writeFileSync(summaryFile, JSON.stringify(results, null, 2));
|
|
282
|
+
console.log("\n====== 测试摘要 ======");
|
|
283
|
+
console.log(`测试URL: ${TEST_URL}`);
|
|
284
|
+
console.log(`测试方法: ${TEST_METHODS.join(', ')}`);
|
|
285
|
+
console.log(`参数组: ${Object.keys(TEST_PARAMS).join(', ')}`);
|
|
286
|
+
// 显示各个方法的内容长度对比
|
|
287
|
+
console.log("\n[各格式内容长度对比]");
|
|
288
|
+
const table = {};
|
|
289
|
+
for (const method of TEST_METHODS) {
|
|
290
|
+
table[method] = {};
|
|
291
|
+
for (const group of Object.keys(TEST_PARAMS)) {
|
|
292
|
+
if (results[method] && results[method][group]) {
|
|
293
|
+
table[method][group] = results[method][group].contentLength;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
table[method][group] = '未测试或失败';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// 格式化为表格输出
|
|
301
|
+
console.log("方法 | " + Object.keys(TEST_PARAMS).join(' | '));
|
|
302
|
+
console.log("-".repeat(50));
|
|
303
|
+
for (const method of TEST_METHODS) {
|
|
304
|
+
let row = `${method} | `;
|
|
305
|
+
for (const group of Object.keys(TEST_PARAMS)) {
|
|
306
|
+
row += `${table[method][group]} | `;
|
|
307
|
+
}
|
|
308
|
+
console.log(row);
|
|
309
|
+
}
|
|
310
|
+
console.log("\n[元数据支持情况]");
|
|
311
|
+
for (const method of TEST_METHODS) {
|
|
312
|
+
if (results[method] && results[method].extraction && results[method].extraction.metadata) {
|
|
313
|
+
console.log(`- ${method}: 支持元数据`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
console.log(`- ${method}: 不支持元数据或测试失败`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
console.log("\n[测试结论]");
|
|
320
|
+
console.log("1. 各参数对内容提取的影响:");
|
|
321
|
+
console.log(" - extractContent: 决定是否进行内容提取处理");
|
|
322
|
+
console.log(" - includeMetadata: 决定是否在结果中包含元数据");
|
|
323
|
+
console.log(" - fallbackToOriginal: 决定内容提取失败时是否回退到原始内容");
|
|
324
|
+
console.log("\n2. 不同格式工具的特点:");
|
|
325
|
+
console.log(" - fetch_html: 提供完整HTML结构");
|
|
326
|
+
console.log(" - fetch_markdown: 提供Markdown格式内容,适合进一步处理");
|
|
327
|
+
console.log(" - fetch_plaintext: 提供纯文本内容,无HTML标签");
|
|
328
|
+
console.log(" - fetch_txt: 类似plaintext,但可能有不同处理");
|
|
329
|
+
console.log(" - fetch_json: 用于JSON数据,不适用于一般网页内容提取");
|
|
330
|
+
console.log("\n3. 内容提取效果最佳的格式:");
|
|
331
|
+
let bestMethod = TEST_METHODS[0];
|
|
332
|
+
let maxDiff = 0;
|
|
333
|
+
for (const method of TEST_METHODS) {
|
|
334
|
+
if (results[method] &&
|
|
335
|
+
results[method].base &&
|
|
336
|
+
results[method].extraction) {
|
|
337
|
+
const diff = Math.abs(results[method].extraction.contentLength - results[method].base.contentLength);
|
|
338
|
+
if (diff > maxDiff) {
|
|
339
|
+
maxDiff = diff;
|
|
340
|
+
bestMethod = method;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
console.log(` - ${bestMethod}格式表现最佳,内容提取效果最明显`);
|
|
345
|
+
console.log("\n====== 摘要结束 ======");
|
|
346
|
+
console.log(`\n[结果保存] 测试结果已保存至 ${TEST_RESULTS_DIR} 目录`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* 运行所有测试 (Run all tests)
|
|
350
|
+
*/
|
|
351
|
+
async function runAllTests() {
|
|
352
|
+
const results = {};
|
|
353
|
+
try {
|
|
354
|
+
// 轮询每个测试方法
|
|
355
|
+
for (const method of TEST_METHODS) {
|
|
356
|
+
console.log(`\n\n======= 开始测试 ${method} =======\n`);
|
|
357
|
+
results[method] = {};
|
|
358
|
+
// 测试各个参数组
|
|
359
|
+
for (const group of Object.keys(TEST_PARAMS)) {
|
|
360
|
+
try {
|
|
361
|
+
console.log(`\n--- 测试 ${method} 使用 ${group} 参数 ---`);
|
|
362
|
+
const result = await runTest(method, group);
|
|
363
|
+
results[method][group] = result;
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
console.error(`测试 ${method} 使用 ${group} 参数失败: ${err.message}`);
|
|
367
|
+
// 继续测试其他参数组
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// 如果基础和提取参数都测试成功,进行比较
|
|
371
|
+
if (results[method].base && results[method].extraction) {
|
|
372
|
+
compareResults(method, results[method].base, results[method].extraction);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// 生成测试摘要
|
|
376
|
+
generateSummary(results);
|
|
377
|
+
console.log("\n[测试完成] 所有测试已完成");
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
console.error(`\n[测试失败] ${err.message}`);
|
|
381
|
+
// 即使有失败,也生成部分摘要
|
|
382
|
+
if (Object.keys(results).length > 0) {
|
|
383
|
+
generateSummary(results);
|
|
384
|
+
}
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// 执行测试 (Execute tests)
|
|
389
|
+
runAllTests();
|