file-toolkit-mcp 1.0.0
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/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/file-toolkit-mcp.js +6 -0
- package/package.json +40 -0
- package/src/lib/batch.js +53 -0
- package/src/lib/csv.js +178 -0
- package/src/lib/dataops.js +78 -0
- package/src/lib/encoding.js +66 -0
- package/src/lib/errors.js +98 -0
- package/src/lib/image.js +331 -0
- package/src/lib/inspect.js +108 -0
- package/src/lib/paths.js +231 -0
- package/src/lib/pdf.js +328 -0
- package/src/lib/registry.js +53 -0
- package/src/lib/rename.js +285 -0
- package/src/lib/result.js +86 -0
- package/src/lib/text.js +80 -0
- package/src/server.js +60 -0
- package/src/tools/data-tools.js +497 -0
- package/src/tools/file-tools.js +348 -0
- package/src/tools/image-tools.js +294 -0
- package/src/tools/pdf-tools.js +335 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { defineTool } from '../lib/registry.js';
|
|
5
|
+
import { runBatch } from '../lib/batch.js';
|
|
6
|
+
import {
|
|
7
|
+
expandInputs,
|
|
8
|
+
resolveOutputDir,
|
|
9
|
+
uniquePath,
|
|
10
|
+
assertNotOverwritingInput,
|
|
11
|
+
displayPath,
|
|
12
|
+
} from '../lib/paths.js';
|
|
13
|
+
import { decodeBuffer, detectDelimiter } from '../lib/encoding.js';
|
|
14
|
+
import {
|
|
15
|
+
parseCsv,
|
|
16
|
+
serializeCsv,
|
|
17
|
+
rowsToRecords,
|
|
18
|
+
recordsToRows,
|
|
19
|
+
collectHeaders,
|
|
20
|
+
} from '../lib/csv.js';
|
|
21
|
+
import { processJson } from '../lib/dataops.js';
|
|
22
|
+
import { markdownToHtml, htmlToMarkdown, guessTitle } from '../lib/text.js';
|
|
23
|
+
import { buildResult, mdTable, summarize, formatBytes } from '../lib/result.js';
|
|
24
|
+
import { describeError, invalid } from '../lib/errors.js';
|
|
25
|
+
|
|
26
|
+
const OUTPUT_DIRECTORY = z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('输出目录。不填则默认写到第一个文件所在目录下的 file-toolkit-output 文件夹,原文件不会被改动。');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 把任意 JSON 结构整理成「表头 + 记录」的形式,供转 CSV 使用。
|
|
33
|
+
*/
|
|
34
|
+
function jsonToTabular(parsed) {
|
|
35
|
+
if (Array.isArray(parsed)) {
|
|
36
|
+
if (parsed.length === 0) return { headers: [], records: [] };
|
|
37
|
+
|
|
38
|
+
const allObjects = parsed.every(
|
|
39
|
+
(item) => item !== null && typeof item === 'object' && !Array.isArray(item),
|
|
40
|
+
);
|
|
41
|
+
if (allObjects) {
|
|
42
|
+
return { headers: collectHeaders(parsed), records: parsed };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const allArrays = parsed.every((item) => Array.isArray(item));
|
|
46
|
+
if (allArrays) {
|
|
47
|
+
const width = Math.max(...parsed.map((row) => row.length));
|
|
48
|
+
const headers = Array.from({ length: width }, (_, i) => `column_${i + 1}`);
|
|
49
|
+
const records = parsed.map((row) =>
|
|
50
|
+
Object.fromEntries(headers.map((key, i) => [key, row[i] ?? ''])),
|
|
51
|
+
);
|
|
52
|
+
return { headers, records };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { headers: ['value'], records: parsed.map((value) => ({ value })) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
59
|
+
const keys = Object.keys(parsed);
|
|
60
|
+
const isColumnar = keys.length > 0 && keys.every((key) => Array.isArray(parsed[key]));
|
|
61
|
+
if (isColumnar) {
|
|
62
|
+
// 列式结构 {"name":["a","b"],"age":[1,2]} -> 行式
|
|
63
|
+
const length = Math.max(...keys.map((key) => parsed[key].length));
|
|
64
|
+
const records = [];
|
|
65
|
+
for (let i = 0; i < length; i += 1) {
|
|
66
|
+
const record = {};
|
|
67
|
+
for (const key of keys) record[key] = parsed[key][i] ?? '';
|
|
68
|
+
records.push(record);
|
|
69
|
+
}
|
|
70
|
+
return { headers: keys, records };
|
|
71
|
+
}
|
|
72
|
+
return { headers: keys, records: [parsed] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw invalid(
|
|
76
|
+
'这个 JSON 不是能转成表格的结构',
|
|
77
|
+
'需要是对象数组,例如 [{"名称":"A","数量":1},{"名称":"B","数量":2}]。',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 把对象里的值拍平成 CSV 单元格可用的字符串 */
|
|
82
|
+
function flattenValue(value) {
|
|
83
|
+
if (value === null || value === undefined) return '';
|
|
84
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerDataTools(server) {
|
|
89
|
+
defineTool(server, {
|
|
90
|
+
name: 'json_format',
|
|
91
|
+
title: '格式化 / 压缩 / 校验 JSON',
|
|
92
|
+
action: '处理 JSON',
|
|
93
|
+
description:
|
|
94
|
+
'美化(缩进)、压缩(去掉空白)或校验 JSON。输入可以是文件,也可以直接把 JSON 文本放进 json_text 参数里。校验失败会指出具体是第几行第几个字符出错。',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
input_files: z.array(z.string()).optional().describe('JSON 文件路径,可以传文件或文件夹。'),
|
|
97
|
+
json_text: z
|
|
98
|
+
.string()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('直接要处理的 JSON 文本。用户在对话里贴了一段 JSON 而不是给文件时用这个。'),
|
|
101
|
+
mode: z
|
|
102
|
+
.enum(['pretty', 'minify', 'validate'])
|
|
103
|
+
.optional()
|
|
104
|
+
.describe('pretty=美化缩进(默认);minify=压缩成一行;validate=只检查合法性不输出内容。'),
|
|
105
|
+
indent: z.number().optional().describe('美化时的缩进空格数,默认 2。'),
|
|
106
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
107
|
+
},
|
|
108
|
+
handler: async (args) => {
|
|
109
|
+
const { input_files, json_text, mode = 'pretty', indent = 2, output_directory } = args;
|
|
110
|
+
const hasFiles = Array.isArray(input_files) && input_files.length > 0;
|
|
111
|
+
|
|
112
|
+
// 情况一:直接给了一段 JSON 文本,结果就地返回,不落盘
|
|
113
|
+
if (!hasFiles) {
|
|
114
|
+
if (!json_text || String(json_text).trim() === '') {
|
|
115
|
+
throw invalid(
|
|
116
|
+
'没有提供要处理的 JSON',
|
|
117
|
+
'可以给我一个 .json 文件的路径,或者直接把 JSON 内容贴给我。',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const result = processJson(json_text, { mode, indent });
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
throw invalid(result.error, '请检查引号、逗号和括号是否配对。');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const shape = result.stats;
|
|
126
|
+
if (mode === 'validate') {
|
|
127
|
+
const detail = shape.type === '数组'
|
|
128
|
+
? `数组,共 ${shape.length} 项`
|
|
129
|
+
: shape.type === '对象'
|
|
130
|
+
? `对象,共 ${shape.keys} 个键`
|
|
131
|
+
: shape.type;
|
|
132
|
+
return `✅ JSON 校验通过:${detail}。`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [
|
|
136
|
+
`✅ JSON 处理完成(${mode === 'minify' ? '已压缩' : '已美化'})。`,
|
|
137
|
+
'',
|
|
138
|
+
'```json',
|
|
139
|
+
result.output,
|
|
140
|
+
'```',
|
|
141
|
+
].join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 情况二:给的是文件,结果写入输出目录
|
|
145
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
146
|
+
extensions: ['json'],
|
|
147
|
+
label: 'JSON 文件',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
151
|
+
|
|
152
|
+
const results = await runBatch(files, async (file) => {
|
|
153
|
+
const raw = await fs.readFile(file);
|
|
154
|
+
const { text, encoding } = decodeBuffer(raw);
|
|
155
|
+
const result = processJson(text, { mode, indent });
|
|
156
|
+
if (!result.ok) throw invalid(result.error);
|
|
157
|
+
|
|
158
|
+
if (mode === 'validate') {
|
|
159
|
+
return { validated: true, encoding, stats: result.stats };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const target = await uniquePath(outputDir, path.basename(file));
|
|
163
|
+
assertNotOverwritingInput(target, files);
|
|
164
|
+
await fs.writeFile(target, result.output, 'utf8');
|
|
165
|
+
return {
|
|
166
|
+
outPath: target,
|
|
167
|
+
bytes: Buffer.byteLength(result.output, 'utf8'),
|
|
168
|
+
encoding,
|
|
169
|
+
stats: result.stats,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const rows = results.map((r) => {
|
|
174
|
+
if (!r.ok) {
|
|
175
|
+
return [path.basename(r.file), '失败', describeError(r.error, { action: '处理' })];
|
|
176
|
+
}
|
|
177
|
+
if (r.validated) {
|
|
178
|
+
return [path.basename(r.file), '格式正确', '—'];
|
|
179
|
+
}
|
|
180
|
+
return [path.basename(r.file), formatBytes(r.bytes), displayPath(r.outPath)];
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const notes = [];
|
|
184
|
+
if (mode === 'validate') notes.push('只做了校验,没有写出新文件。');
|
|
185
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
186
|
+
|
|
187
|
+
return buildResult({
|
|
188
|
+
title: `🧩 ${summarize('JSON 处理', results)}`,
|
|
189
|
+
table: mdTable(['文件', '结果', '输出'], rows),
|
|
190
|
+
notes,
|
|
191
|
+
outputDir: mode === 'validate' ? null : displayPath(outputDir),
|
|
192
|
+
data: {
|
|
193
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
194
|
+
failed: results.filter((r) => !r.ok).length,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
defineTool(server, {
|
|
201
|
+
name: 'csv_to_json',
|
|
202
|
+
title: 'CSV 转 JSON',
|
|
203
|
+
action: '转换 CSV',
|
|
204
|
+
description:
|
|
205
|
+
'把 CSV / TSV 转成 JSON 数组(第一行当表头)。自动识别 UTF-8 与 GBK 编码,中文不会乱码;引号包裹的逗号、换行、双引号都能正确处理。支持批量。反向操作用 json_to_csv。',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
input_files: z.array(z.string()).describe('CSV 文件路径,可以传文件或文件夹,支持多个。'),
|
|
208
|
+
delimiter: z
|
|
209
|
+
.string()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('分隔符,默认自动识别(逗号 / 制表符 / 分号 / 竖线)。'),
|
|
212
|
+
encoding: z
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe('文件编码,默认自动识别(UTF-8 或 GBK)。乱码时可以明确指定 utf-8 或 gbk。'),
|
|
216
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
217
|
+
},
|
|
218
|
+
handler: async (args) => {
|
|
219
|
+
const { input_files, delimiter, encoding, output_directory } = args;
|
|
220
|
+
|
|
221
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
222
|
+
extensions: ['csv', 'tsv'],
|
|
223
|
+
label: 'CSV 文件',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
227
|
+
|
|
228
|
+
const results = await runBatch(files, async (file) => {
|
|
229
|
+
const raw = await fs.readFile(file);
|
|
230
|
+
const decoded = decodeBuffer(raw, encoding ?? null);
|
|
231
|
+
const sep = delimiter || (path.extname(file).toLowerCase() === '.tsv'
|
|
232
|
+
? '\t'
|
|
233
|
+
: detectDelimiter(decoded.text));
|
|
234
|
+
|
|
235
|
+
const rows = parseCsv(decoded.text, { delimiter: sep });
|
|
236
|
+
const { headers, records } = rowsToRecords(rows);
|
|
237
|
+
if (headers.length === 0) {
|
|
238
|
+
throw invalid('这个文件里没有可读取的内容');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const json = JSON.stringify(records, null, 2);
|
|
242
|
+
const stem = path.basename(file, path.extname(file));
|
|
243
|
+
const target = await uniquePath(outputDir, `${stem}.json`);
|
|
244
|
+
assertNotOverwritingInput(target, files);
|
|
245
|
+
await fs.writeFile(target, json, 'utf8');
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
outPath: target,
|
|
249
|
+
rows: records.length,
|
|
250
|
+
columns: headers.length,
|
|
251
|
+
encoding: decoded.encoding,
|
|
252
|
+
delimiter: sep === '\t' ? '制表符' : sep,
|
|
253
|
+
bytes: Buffer.byteLength(json, 'utf8'),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const rows = results.map((r) => {
|
|
258
|
+
if (!r.ok) {
|
|
259
|
+
return [path.basename(r.file), '—', '—', describeError(r.error, { action: '转换' })];
|
|
260
|
+
}
|
|
261
|
+
return [
|
|
262
|
+
path.basename(r.file),
|
|
263
|
+
`${r.rows} 行 × ${r.columns} 列`,
|
|
264
|
+
`${r.encoding} / ${r.delimiter}`,
|
|
265
|
+
displayPath(r.outPath),
|
|
266
|
+
];
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const notes = ['第一行被当作表头,转换结果是对象数组。'];
|
|
270
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
271
|
+
|
|
272
|
+
return buildResult({
|
|
273
|
+
title: `📊 ${summarize('CSV 转 JSON', results)}`,
|
|
274
|
+
table: mdTable(['文件', '规模', '识别到的编码 / 分隔符', '输出'], rows),
|
|
275
|
+
notes,
|
|
276
|
+
outputDir: displayPath(outputDir),
|
|
277
|
+
data: {
|
|
278
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
279
|
+
failed: results.filter((r) => !r.ok).length,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
defineTool(server, {
|
|
286
|
+
name: 'json_to_csv',
|
|
287
|
+
title: 'JSON 转 CSV',
|
|
288
|
+
action: '转换 JSON',
|
|
289
|
+
description:
|
|
290
|
+
'把 JSON 对象数组转成 CSV 表格。输出的 CSV 带 UTF-8 BOM,Excel / WPS 直接双击打开中文不乱码。支持对象数组、二维数组、列式对象。反向操作用 csv_to_json。',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
input_files: z.array(z.string()).optional().describe('JSON 文件路径,可以传文件或文件夹。'),
|
|
293
|
+
json_text: z.string().optional().describe('直接要转换的 JSON 文本。'),
|
|
294
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
295
|
+
},
|
|
296
|
+
handler: async (args) => {
|
|
297
|
+
const { input_files, json_text, output_directory } = args;
|
|
298
|
+
const hasFiles = Array.isArray(input_files) && input_files.length > 0;
|
|
299
|
+
|
|
300
|
+
if (!hasFiles) {
|
|
301
|
+
if (!json_text || String(json_text).trim() === '') {
|
|
302
|
+
throw invalid('没有提供要转换的 JSON', '可以给 .json 文件路径,或直接贴 JSON 内容。');
|
|
303
|
+
}
|
|
304
|
+
const parsed = processJson(json_text, { mode: 'validate' });
|
|
305
|
+
if (!parsed.ok) throw invalid(parsed.error);
|
|
306
|
+
|
|
307
|
+
const { headers, records } = jsonToTabular(parsed.parsed);
|
|
308
|
+
if (headers.length === 0) throw invalid('这个 JSON 里没有可转成表格的数据');
|
|
309
|
+
|
|
310
|
+
const csv = serializeCsv(headers, recordsToRows(records, headers).map((row) => row.map(flattenValue)));
|
|
311
|
+
return [
|
|
312
|
+
`✅ 已转换:${records.length} 行 × ${headers.length} 列。`,
|
|
313
|
+
'',
|
|
314
|
+
'```csv',
|
|
315
|
+
csv.trimEnd(),
|
|
316
|
+
'```',
|
|
317
|
+
].join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
321
|
+
extensions: ['json'],
|
|
322
|
+
label: 'JSON 文件',
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
326
|
+
|
|
327
|
+
const results = await runBatch(files, async (file) => {
|
|
328
|
+
const raw = await fs.readFile(file);
|
|
329
|
+
const { text } = decodeBuffer(raw);
|
|
330
|
+
const parsed = processJson(text, { mode: 'validate' });
|
|
331
|
+
if (!parsed.ok) throw invalid(parsed.error);
|
|
332
|
+
|
|
333
|
+
const { headers, records } = jsonToTabular(parsed.parsed);
|
|
334
|
+
if (headers.length === 0) throw invalid('这个 JSON 里没有可转成表格的数据');
|
|
335
|
+
|
|
336
|
+
const csv = serializeCsv(headers, recordsToRows(records, headers).map((row) => row.map(flattenValue)));
|
|
337
|
+
const stem = path.basename(file, path.extname(file));
|
|
338
|
+
const target = await uniquePath(outputDir, `${stem}.csv`);
|
|
339
|
+
assertNotOverwritingInput(target, files);
|
|
340
|
+
await fs.writeFile(target, csv, 'utf8');
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
outPath: target,
|
|
344
|
+
rows: records.length,
|
|
345
|
+
columns: headers.length,
|
|
346
|
+
bytes: Buffer.byteLength(csv, 'utf8'),
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const rows = results.map((r) => {
|
|
351
|
+
if (!r.ok) {
|
|
352
|
+
return [path.basename(r.file), '—', describeError(r.error, { action: '转换' }), '—'];
|
|
353
|
+
}
|
|
354
|
+
return [
|
|
355
|
+
path.basename(r.file),
|
|
356
|
+
`${r.rows} 行 × ${r.columns} 列`,
|
|
357
|
+
formatBytes(r.bytes),
|
|
358
|
+
displayPath(r.outPath),
|
|
359
|
+
];
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const notes = ['输出的 CSV 带 UTF-8 BOM,Excel / WPS 双击打开不会乱码。'];
|
|
363
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
364
|
+
|
|
365
|
+
return buildResult({
|
|
366
|
+
title: `📊 ${summarize('JSON 转 CSV', results)}`,
|
|
367
|
+
table: mdTable(['文件', '规模', '大小', '输出'], rows),
|
|
368
|
+
notes,
|
|
369
|
+
outputDir: displayPath(outputDir),
|
|
370
|
+
data: {
|
|
371
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
372
|
+
failed: results.filter((r) => !r.ok).length,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
defineTool(server, {
|
|
379
|
+
name: 'markdown_to_html',
|
|
380
|
+
title: 'Markdown 转 HTML',
|
|
381
|
+
action: '转换 Markdown',
|
|
382
|
+
description:
|
|
383
|
+
'把 .md 文件转成 HTML。默认输出带样式、可直接在浏览器打开的完整网页;把 full_page 设为 false 则只输出 HTML 片段(方便嵌到别的页面里)。反向操作用 html_to_markdown。',
|
|
384
|
+
inputSchema: {
|
|
385
|
+
input_files: z.array(z.string()).describe('Markdown 文件路径,可以传文件或文件夹,支持多个。'),
|
|
386
|
+
full_page: z
|
|
387
|
+
.boolean()
|
|
388
|
+
.optional()
|
|
389
|
+
.describe('true=输出完整网页(默认,带样式和标题);false=只输出 HTML 片段。'),
|
|
390
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
391
|
+
},
|
|
392
|
+
handler: async (args) => {
|
|
393
|
+
const { input_files, full_page = true, output_directory } = args;
|
|
394
|
+
|
|
395
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
396
|
+
extensions: ['md', 'markdown', 'mdown', 'mkd'],
|
|
397
|
+
label: 'Markdown 文件',
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
401
|
+
|
|
402
|
+
const results = await runBatch(files, async (file) => {
|
|
403
|
+
const raw = await fs.readFile(file);
|
|
404
|
+
const { text } = decodeBuffer(raw);
|
|
405
|
+
const html = markdownToHtml(text, { fullPage: full_page, title: guessTitle(text) });
|
|
406
|
+
const stem = path.basename(file, path.extname(file));
|
|
407
|
+
const target = await uniquePath(outputDir, `${stem}.html`);
|
|
408
|
+
assertNotOverwritingInput(target, files);
|
|
409
|
+
await fs.writeFile(target, html, 'utf8');
|
|
410
|
+
return {
|
|
411
|
+
outPath: target,
|
|
412
|
+
bytes: Buffer.byteLength(html, 'utf8'),
|
|
413
|
+
title: guessTitle(text),
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const rows = results.map((r) => {
|
|
418
|
+
if (!r.ok) {
|
|
419
|
+
return [path.basename(r.file), '—', describeError(r.error, { action: '转换' })];
|
|
420
|
+
}
|
|
421
|
+
return [path.basename(r.file), formatBytes(r.bytes), displayPath(r.outPath)];
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const notes = [
|
|
425
|
+
full_page
|
|
426
|
+
? '输出的是完整网页,可直接用浏览器打开,已内置基础排版样式。'
|
|
427
|
+
: '只输出了 HTML 片段。',
|
|
428
|
+
];
|
|
429
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
430
|
+
|
|
431
|
+
return buildResult({
|
|
432
|
+
title: `🌐 ${summarize('Markdown 转 HTML', results)}`,
|
|
433
|
+
table: mdTable(['文件', '大小', '输出'], rows),
|
|
434
|
+
notes,
|
|
435
|
+
outputDir: displayPath(outputDir),
|
|
436
|
+
data: {
|
|
437
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
438
|
+
failed: results.filter((r) => !r.ok).length,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
defineTool(server, {
|
|
445
|
+
name: 'html_to_markdown',
|
|
446
|
+
title: 'HTML 转 Markdown',
|
|
447
|
+
action: '转换 HTML',
|
|
448
|
+
description:
|
|
449
|
+
'把 .html 文件转成 Markdown 文本。表格、代码块、列表、标题都会被保留。反向操作用 markdown_to_html。',
|
|
450
|
+
inputSchema: {
|
|
451
|
+
input_files: z.array(z.string()).describe('HTML 文件路径,可以传文件或文件夹,支持多个。'),
|
|
452
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
453
|
+
},
|
|
454
|
+
handler: async (args) => {
|
|
455
|
+
const { input_files, output_directory } = args;
|
|
456
|
+
|
|
457
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
458
|
+
extensions: ['html', 'htm'],
|
|
459
|
+
label: 'HTML 文件',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
463
|
+
|
|
464
|
+
const results = await runBatch(files, async (file) => {
|
|
465
|
+
const raw = await fs.readFile(file);
|
|
466
|
+
const { text } = decodeBuffer(raw);
|
|
467
|
+
const markdown = htmlToMarkdown(text);
|
|
468
|
+
const stem = path.basename(file, path.extname(file));
|
|
469
|
+
const target = await uniquePath(outputDir, `${stem}.md`);
|
|
470
|
+
assertNotOverwritingInput(target, files);
|
|
471
|
+
await fs.writeFile(target, markdown, 'utf8');
|
|
472
|
+
return { outPath: target, bytes: Buffer.byteLength(markdown, 'utf8') };
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const rows = results.map((r) => {
|
|
476
|
+
if (!r.ok) {
|
|
477
|
+
return [path.basename(r.file), '—', describeError(r.error, { action: '转换' })];
|
|
478
|
+
}
|
|
479
|
+
return [path.basename(r.file), formatBytes(r.bytes), displayPath(r.outPath)];
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const notes = ['表格和代码块会保留为 Markdown 语法。'];
|
|
483
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
484
|
+
|
|
485
|
+
return buildResult({
|
|
486
|
+
title: `📝 ${summarize('HTML 转 Markdown', results)}`,
|
|
487
|
+
table: mdTable(['文件', '大小', '输出'], rows),
|
|
488
|
+
notes,
|
|
489
|
+
outputDir: displayPath(outputDir),
|
|
490
|
+
data: {
|
|
491
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
492
|
+
failed: results.filter((r) => !r.ok).length,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|