@raolin2025/claude-code-node 2.8.1 → 2.8.3
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 +65 -3
- package/package.json +1 -1
- package/src/__tests__/web-fetch-guard.test.js +193 -0
- package/src/core/cli.js +79 -2
- package/src/core/config.js +10 -0
- package/src/core/context-window.js +262 -0
- package/src/core/index.js +8 -0
- package/src/core/query-engine.js +15 -0
- package/src/core/token-budget.js +14 -0
- package/src/security/fetch-guard.js +311 -0
- package/src/security/redact.js +76 -0
- package/src/tools/web-fetch-providers.js +140 -0
- package/src/tools/web-fetch.js +114 -60
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏感数据自动脱敏
|
|
3
|
+
*
|
|
4
|
+
* 移植自 openclaw safe-jina-fetch 设计(DESIGN.md §4.4)。
|
|
5
|
+
* 对响应内容扫描常见敏感模式,命中即替换为 [REDACTED:类型],并在 warnings 中告警。
|
|
6
|
+
* (原 Python 版只告警不脱敏,这里按 v2.0 设计升级为自动脱敏)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 各类敏感模式的检测规则:{ 类型, 正则, 替换值 } */
|
|
10
|
+
const REDACT_RULES = [
|
|
11
|
+
// API Key / Access Token / Auth Token(key= / token= 等 8 位以上值)
|
|
12
|
+
{
|
|
13
|
+
type: 'API-Key',
|
|
14
|
+
regex: /(key|token|api[_-]?key|access[_-]?token|auth[_-]?token|secret)=(['"]?)[A-Za-z0-9_\-./+]{8,}\2/gi,
|
|
15
|
+
value: '[REDACTED:API-Key]',
|
|
16
|
+
},
|
|
17
|
+
// Bearer Token(20 位以上)
|
|
18
|
+
{
|
|
19
|
+
type: 'Bearer',
|
|
20
|
+
regex: /Bearer\s+[A-Za-z0-9_\-.]{20,}/gi,
|
|
21
|
+
value: '[REDACTED:Bearer]',
|
|
22
|
+
},
|
|
23
|
+
// AWS Access Key(AKIA 开头 16 位)
|
|
24
|
+
{
|
|
25
|
+
type: 'AWS-Key',
|
|
26
|
+
regex: /AKIA[0-9A-Z]{16}/g,
|
|
27
|
+
value: '[REDACTED:AWS-Key]',
|
|
28
|
+
},
|
|
29
|
+
// AWS Secret Access Key
|
|
30
|
+
{
|
|
31
|
+
type: 'AWS-Secret',
|
|
32
|
+
regex: /aws_secret_access_key\s*=\s*['"]?[A-Za-z0-9/+=]{20,}/gi,
|
|
33
|
+
value: '[REDACTED:AWS-Secret]',
|
|
34
|
+
},
|
|
35
|
+
// 私钥
|
|
36
|
+
{
|
|
37
|
+
type: 'Private-Key',
|
|
38
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
39
|
+
value: '[REDACTED:Private-Key]',
|
|
40
|
+
},
|
|
41
|
+
// OpenAI 风格(sk- + 20 位以上字母数字)
|
|
42
|
+
{
|
|
43
|
+
type: 'OpenAI-Key',
|
|
44
|
+
regex: /sk-[A-Za-z0-9]{20,}/g,
|
|
45
|
+
value: '[REDACTED:OpenAI-Key]',
|
|
46
|
+
},
|
|
47
|
+
// Slack Token(xoxb / xoxa / xoxp / xoxr)
|
|
48
|
+
{
|
|
49
|
+
type: 'Slack-Token',
|
|
50
|
+
regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
51
|
+
value: '[REDACTED:Slack-Token]',
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 对文本执行敏感数据脱敏
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {{text: string, redacted: string[]}} — 脱敏后的文本 + 命中的类型列表(去重)
|
|
59
|
+
*/
|
|
60
|
+
export function redactSensitiveData(text) {
|
|
61
|
+
if (typeof text !== 'string' || !text) return { text: text || '', redacted: [] }
|
|
62
|
+
|
|
63
|
+
let result = text
|
|
64
|
+
const hitTypes = new Set()
|
|
65
|
+
|
|
66
|
+
for (const rule of REDACT_RULES) {
|
|
67
|
+
if (rule.regex.test(result)) {
|
|
68
|
+
hitTypes.add(rule.type)
|
|
69
|
+
// 重置 lastIndex(/g 正则 test 会改变 lastIndex)
|
|
70
|
+
rule.regex.lastIndex = 0
|
|
71
|
+
result = result.replace(rule.regex, rule.value)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { text: result, redacted: [...hitTypes] }
|
|
76
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch 兜底 Provider — Safe Jina Reader
|
|
3
|
+
*
|
|
4
|
+
* 移植自 openclaw safe-jina-fetch 设计(DESIGN.md §5)。
|
|
5
|
+
*
|
|
6
|
+
* 作用:当 WebFetch 直连目标 URL 失败(非 ok / 网络错误 / 超时)时,
|
|
7
|
+
* 自动经 Jina Reader(https://r.jina.ai/<url>)清洗后返回 Markdown,
|
|
8
|
+
* 专门对付反爬 / 403 / 动态渲染站点。
|
|
9
|
+
*
|
|
10
|
+
* Jina 请求模式(对齐 DESIGN.md §5.3):
|
|
11
|
+
* - CLI / 默认: text/plain + X-Return-Format: markdown → 首行 Title: xxx 提取标题
|
|
12
|
+
* - json: application/json → {title, url, content} 更稳健
|
|
13
|
+
*
|
|
14
|
+
* 凭据(三选一,可选——匿名约 20 RPM):
|
|
15
|
+
* 1. 环境变量 JINA_API_KEY / JINA_READER_KEY
|
|
16
|
+
* 2. 配置 web.fetch.jinaApiKey
|
|
17
|
+
* 3. 匿名
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const JINA_READER_BASE = 'https://r.jina.ai'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从 Jina Reader 返回的 markdown 文本中提取标题(首行 "Title: xxx")
|
|
24
|
+
* @param {string} markdown
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function extractTitleFromMarkdown(markdown) {
|
|
28
|
+
const lines = (markdown || '').split('\n')
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const m = line.match(/^\s*Title:\s*(.+)$/i)
|
|
31
|
+
if (m) return m[1].trim()
|
|
32
|
+
}
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析 Jina 返回的 markdown,提取正文(去掉 Title/URL 元信息行)
|
|
38
|
+
* @param {string} markdown
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function cleanJinaMarkdown(markdown) {
|
|
42
|
+
if (typeof markdown !== 'string') return ''
|
|
43
|
+
const lines = markdown.split('\n')
|
|
44
|
+
|
|
45
|
+
// 优先找 "Markdown Content:" 标记,从其后开始
|
|
46
|
+
const mcIdx = lines.findIndex(l => /^Markdown Content:/i.test(l.trim()))
|
|
47
|
+
if (mcIdx >= 0) return lines.slice(mcIdx + 1).join('\n').trim()
|
|
48
|
+
|
|
49
|
+
// 否则去掉开头的 Title: / URL Source: 元信息行
|
|
50
|
+
const bodyStart = lines.findIndex(l => !/^(Title:|URL Source:)/i.test(l.trim()))
|
|
51
|
+
if (bodyStart > 0) return lines.slice(bodyStart).join('\n').trim()
|
|
52
|
+
|
|
53
|
+
return markdown.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 调用 Jina Reader 抓取 URL
|
|
58
|
+
* @param {string} url — 目标 URL
|
|
59
|
+
* @param {object} [options]
|
|
60
|
+
* @param {string} [options.apiKey] — Jina 凭据(可选,匿名可用)
|
|
61
|
+
* @param {'markdown'|'json'} [options.format] — 返回格式
|
|
62
|
+
* @param {number} [options.timeoutMs]
|
|
63
|
+
* @returns {Promise<{ok: boolean, text: string, title: string, finalUrl: string, status?: number, error?: string}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function safeJinaFetch(url, options = {}) {
|
|
66
|
+
const { apiKey = '', format = 'markdown', timeoutMs = 30000 } = options
|
|
67
|
+
|
|
68
|
+
// 校验目标 URL 基本合法性(协议必须是 http/https)
|
|
69
|
+
let parsed
|
|
70
|
+
try {
|
|
71
|
+
parsed = new URL(url)
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false, text: '', title: '', finalUrl: url, error: `无效的 URL: ${url}` }
|
|
74
|
+
}
|
|
75
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
76
|
+
return { ok: false, text: '', title: '', finalUrl: url, error: `不支持的协议:${parsed.protocol}` }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const jinaUrl = `${JINA_READER_BASE}/${url}`
|
|
80
|
+
const headers = {
|
|
81
|
+
'User-Agent': 'cc-node/2.8.2',
|
|
82
|
+
'Accept': format === 'json' ? 'application/json' : 'text/plain',
|
|
83
|
+
}
|
|
84
|
+
if (format === 'markdown') headers['X-Return-Format'] = 'markdown'
|
|
85
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(jinaUrl, { headers, signal: AbortSignal.timeout(timeoutMs) })
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
text: '',
|
|
94
|
+
title: '',
|
|
95
|
+
finalUrl: jinaUrl,
|
|
96
|
+
status: response.status,
|
|
97
|
+
error: `Jina Reader HTTP ${response.status}`,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (format === 'json') {
|
|
102
|
+
const data = await response.json()
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
text: typeof data.content === 'string' ? data.content : '',
|
|
106
|
+
title: typeof data.title === 'string' ? data.title : '',
|
|
107
|
+
finalUrl: jinaUrl,
|
|
108
|
+
status: 200,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const markdown = await response.text()
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
text: cleanJinaMarkdown(markdown),
|
|
116
|
+
title: extractTitleFromMarkdown(markdown),
|
|
117
|
+
finalUrl: jinaUrl,
|
|
118
|
+
status: 200,
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.name === 'TimeoutError') {
|
|
122
|
+
return { ok: false, text: '', title: '', finalUrl: jinaUrl, error: 'Jina Reader 请求超时' }
|
|
123
|
+
}
|
|
124
|
+
return { ok: false, text: '', title: '', finalUrl: jinaUrl, error: `Jina Reader 错误: ${err.message}` }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析 Jina 凭据(三选一:环境变量 > 配置 > 匿名)
|
|
130
|
+
* @param {object} [config] — Config 实例(可选)
|
|
131
|
+
* @returns {string} apiKey(可能为空 = 匿名)
|
|
132
|
+
*/
|
|
133
|
+
export function resolveJinaApiKey(config = null) {
|
|
134
|
+
return (
|
|
135
|
+
process.env.JINA_API_KEY ||
|
|
136
|
+
process.env.JINA_READER_KEY ||
|
|
137
|
+
(config ? config.get('web.fetch.jinaApiKey') || '' : '') ||
|
|
138
|
+
''
|
|
139
|
+
)
|
|
140
|
+
}
|
package/src/tools/web-fetch.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebFetch 工具 —
|
|
3
|
-
*
|
|
2
|
+
* WebFetch 工具 — 抓取网页内容(安全管道 + Jina 兜底)
|
|
3
|
+
*
|
|
4
|
+
* 安全特性(移植自 safe-jina-fetch 设计):
|
|
5
|
+
* - 协议白名单:仅 http/https,file/ftp/data 拒绝
|
|
6
|
+
* - 连接级 SSRF 防护:TCP 连接时逐地址校验(防 DNS rebinding)
|
|
7
|
+
* - 重定向逐跳校验:最多 5 跳,每跳重新校验
|
|
8
|
+
* - 响应大小上限(10MB)、超时(30s)、强制 SSL
|
|
9
|
+
* - 敏感数据自动脱敏
|
|
10
|
+
*
|
|
11
|
+
* 兜底机制:
|
|
12
|
+
* - 直连成功(2xx)→ 返回清洗后的文本/JSON
|
|
13
|
+
* - 直连失败(非 2xx / 网络错误 / 超时)→ 自动经 Jina Reader 清洗后返回 Markdown
|
|
14
|
+
* - 直连 200 但正文过短(< 200 字符,疑似 JS 挑战页)→ 也触发 Jina 兜底
|
|
15
|
+
*
|
|
16
|
+
* extractMode 参数:
|
|
17
|
+
* - auto (默认)直连优先,失败/异常走 Jina 兜底
|
|
18
|
+
* - direct 强制直连,不兜底
|
|
19
|
+
* - jina 强制直接走 Jina Reader(最干净,永远清洗)
|
|
4
20
|
*/
|
|
5
21
|
import { ToolDef } from '../types/index.js'
|
|
6
|
-
import {
|
|
22
|
+
import { safeFetchWithRedirects, DEFAULT_FETCH_OPTIONS } from '../security/fetch-guard.js'
|
|
23
|
+
import { redactSensitiveData } from '../security/redact.js'
|
|
24
|
+
import { safeJinaFetch, resolveJinaApiKey } from './web-fetch-providers.js'
|
|
7
25
|
|
|
8
|
-
const
|
|
26
|
+
const DEFAULT_MAX_CHARS = 100000
|
|
27
|
+
// 正文过短阈值:低于此字符数视为疑似 JS 挑战页,触发 Jina 兜底(对齐 DESIGN.md §8 增强)
|
|
28
|
+
const MIN_BODY_CHARS = 200
|
|
9
29
|
|
|
10
30
|
/**
|
|
11
31
|
* 简单的 HTML → 纯文本转换
|
|
@@ -35,7 +55,7 @@ function htmlToText(html) {
|
|
|
35
55
|
return text
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
const VERSION = '2.
|
|
58
|
+
const VERSION = '2.1.0'
|
|
39
59
|
|
|
40
60
|
export const webFetchTool = new ToolDef(
|
|
41
61
|
'WebFetch',
|
|
@@ -43,7 +63,9 @@ export const webFetchTool = new ToolDef(
|
|
|
43
63
|
Usage:
|
|
44
64
|
- url must be a valid HTTP/HTTPS URL
|
|
45
65
|
- Returns the page content as cleaned text/markdown
|
|
46
|
-
- Supports HTML pages, plain text, and JSON APIs
|
|
66
|
+
- Supports HTML pages, plain text, and JSON APIs
|
|
67
|
+
- If direct fetch fails (403/anti-crawl/network error), automatically falls back to Jina Reader for a clean Markdown version
|
|
68
|
+
- extractMode: auto (direct first, fallback on failure) | direct (force direct only) | jina (always use Jina Reader)`,
|
|
47
69
|
{
|
|
48
70
|
type: 'object',
|
|
49
71
|
properties: {
|
|
@@ -56,72 +78,104 @@ Usage:
|
|
|
56
78
|
enum: ['text', 'json', 'raw'],
|
|
57
79
|
description: 'Output format: text (cleaned HTML), json (parse as JSON), raw (raw response)',
|
|
58
80
|
},
|
|
81
|
+
extractMode: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
enum: ['auto', 'direct', 'jina'],
|
|
84
|
+
description: 'Extraction mode: auto (default, direct first + Jina fallback), direct (force direct), jina (always Jina Reader)',
|
|
85
|
+
},
|
|
59
86
|
},
|
|
60
87
|
required: ['url'],
|
|
61
88
|
},
|
|
62
89
|
async (input, ctx) => {
|
|
63
|
-
const { url, format = 'text' } = input
|
|
90
|
+
const { url, format = 'text', extractMode = 'auto' } = input
|
|
91
|
+
|
|
92
|
+
const config = ctx?.engine?.config?.configStore || null
|
|
93
|
+
const maxChars = config?.get?.('web.fetch.maxChars') || DEFAULT_MAX_CHARS
|
|
94
|
+
const maxBytes = config?.get?.('web.fetch.maxBytes') || DEFAULT_FETCH_OPTIONS.maxBytes
|
|
95
|
+
const timeoutMs = config?.get?.('web.fetch.timeoutMs') || DEFAULT_FETCH_OPTIONS.timeoutMs
|
|
96
|
+
const maxRedirects = config?.get?.('web.fetch.maxRedirects') || DEFAULT_FETCH_OPTIONS.maxRedirects
|
|
97
|
+
const jinaApiKey = resolveJinaApiKey(config)
|
|
64
98
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return `[
|
|
99
|
+
// extractMode=jina:强制直接走 Jina(永远清洗)
|
|
100
|
+
if (extractMode === 'jina') {
|
|
101
|
+
const jr = await safeJinaFetch(url, { apiKey: jinaApiKey, format: 'markdown', timeoutMs })
|
|
102
|
+
if (!jr.ok) return `[Error: ${jr.error}]`
|
|
103
|
+
const { text, redacted } = redactSensitiveData(jr.text)
|
|
104
|
+
const out = (jr.title ? `# ${jr.title}\n\n` : '') + text
|
|
105
|
+
const warning = redacted.length ? `\n\n[⚠️ 已脱敏: ${redacted.join(', ')}]` : ''
|
|
106
|
+
return (out + warning).slice(0, maxChars)
|
|
69
107
|
}
|
|
70
108
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
headers: {
|
|
74
|
-
'User-Agent': `ClaudeCode-Node/${VERSION}`,
|
|
75
|
-
'Accept': 'text/html,application/json,text/plain,*/*',
|
|
76
|
-
},
|
|
77
|
-
signal: AbortSignal.timeout(30000),
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
if (!response.ok) {
|
|
81
|
-
return `[HTTP ${response.status} ${response.statusText}]`
|
|
82
|
-
}
|
|
109
|
+
// 直连(安全管道:协议 + 连接级 SSRF + 重定向逐跳校验 + 大小/超时/SSL)
|
|
110
|
+
const result = await safeFetchWithRedirects(url, { timeoutMs, maxBytes, maxRedirects })
|
|
83
111
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (format === 'json' || contentType.includes('application/json')) {
|
|
89
|
-
try {
|
|
90
|
-
const data = JSON.parse(body)
|
|
91
|
-
const formatted = JSON.stringify(data, null, 2)
|
|
92
|
-
return formatted.length > MAX_FETCH_CHARS
|
|
93
|
-
? formatted.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
94
|
-
: formatted
|
|
95
|
-
} catch {
|
|
96
|
-
return body.slice(0, MAX_FETCH_CHARS)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
112
|
+
// extractMode=direct:不兜底,直接返回直连结果(无论成败)
|
|
113
|
+
if (extractMode === 'direct') {
|
|
114
|
+
return formatDirectResult(result, format, maxChars)
|
|
115
|
+
}
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
// auto 模式:直连失败或内容过短 → 走 Jina 兜底
|
|
118
|
+
const failed = !result.ok || result.error
|
|
119
|
+
const tooShort = result.ok && result.body.trim().length < MIN_BODY_CHARS
|
|
120
|
+
if (failed || tooShort) {
|
|
121
|
+
if (config?.get?.('verbose')) {
|
|
122
|
+
console.error(`[web-fetch] direct ${failed ? `failed (${result.error || result.status})` : 'content too short'}, falling back to Jina Reader`)
|
|
105
123
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
: text
|
|
124
|
+
const jr = await safeJinaFetch(url, { apiKey: jinaApiKey, format: 'markdown', timeoutMs })
|
|
125
|
+
if (jr.ok) {
|
|
126
|
+
const { text, redacted } = redactSensitiveData(jr.text)
|
|
127
|
+
const out = (jr.title ? `# ${jr.title}\n\n` : '') + text
|
|
128
|
+
const warning = redacted.length ? `\n\n[⚠️ 已脱敏: ${redacted.join(', ')}]` : ''
|
|
129
|
+
return (out + warning).slice(0, maxChars)
|
|
113
130
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? body.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
118
|
-
: body
|
|
119
|
-
} catch (err) {
|
|
120
|
-
if (err.name === 'TimeoutError') {
|
|
121
|
-
return `[Error: Request timed out after 30s]`
|
|
122
|
-
}
|
|
123
|
-
return `[Error fetching URL: ${err.message}]`
|
|
131
|
+
// Jina 也失败 — 返回直连结果 + Jina 错误说明
|
|
132
|
+
return formatDirectResult(result, format, maxChars) +
|
|
133
|
+
`\n\n[Jina fallback also failed: ${jr.error}]`
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
// 直连成功 — 返回直连结果
|
|
137
|
+
return formatDirectResult(result, format, maxChars)
|
|
125
138
|
},
|
|
126
139
|
'ask'
|
|
127
140
|
)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 格式化直连结果(含脱敏)
|
|
144
|
+
* @param {object} result — safeFetchWithRedirects 返回值
|
|
145
|
+
* @param {string} format
|
|
146
|
+
* @param {number} maxChars
|
|
147
|
+
*/
|
|
148
|
+
function formatDirectResult(result, format, maxChars) {
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
if (result.error) return `[Error: ${result.error}]`
|
|
151
|
+
return `[HTTP ${result.status} ${result.statusText || ''}]`.trim()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contentType = result.headers['content-type'] || ''
|
|
155
|
+
const body = result.body
|
|
156
|
+
|
|
157
|
+
let out
|
|
158
|
+
if (format === 'json' || contentType.includes('application/json')) {
|
|
159
|
+
try {
|
|
160
|
+
const data = JSON.parse(body)
|
|
161
|
+
out = JSON.stringify(data, null, 2)
|
|
162
|
+
} catch {
|
|
163
|
+
out = body
|
|
164
|
+
}
|
|
165
|
+
} else if (format === 'raw') {
|
|
166
|
+
out = body
|
|
167
|
+
} else if (contentType.includes('text/html')) {
|
|
168
|
+
out = htmlToText(body)
|
|
169
|
+
} else {
|
|
170
|
+
out = body
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 敏感数据脱敏
|
|
174
|
+
const { text, redacted } = redactSensitiveData(out)
|
|
175
|
+
let final = text
|
|
176
|
+
if (result.truncated) final += '\n[...truncated]'
|
|
177
|
+
if (redacted.length) final += `\n[⚠️ 已脱敏: ${redacted.join(', ')}]`
|
|
178
|
+
if (result.redirects?.length) final += `\n[重定向: ${result.redirects.join(' → ')}]`
|
|
179
|
+
|
|
180
|
+
return final.length > maxChars ? final.slice(0, maxChars) + '\n[...truncated]' : final
|
|
181
|
+
}
|