@ww_nero/mini-cli 1.0.61 → 1.0.63
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/package.json +1 -1
- package/src/config.js +401 -376
- package/src/tools/bash.js +12 -12
- package/src/tools/index.js +13 -4
- package/src/tools/mcp.js +5 -4
- package/src/tools/convert.js +0 -297
- package/src/tools/python/html_to_png.py +0 -100
- package/src/tools/python/html_to_pptx.py +0 -163
- package/src/tools/python/pdf_to_png.py +0 -58
- package/src/tools/python/pptx_to_pdf.py +0 -107
package/src/tools/bash.js
CHANGED
|
@@ -2,10 +2,6 @@ const { spawn } = require('child_process');
|
|
|
2
2
|
const { resolveWorkspacePath } = require('../utils/helpers');
|
|
3
3
|
const { DEFAULT_ALLOWED_COMMANDS } = require('../config');
|
|
4
4
|
|
|
5
|
-
const OUTPUT_MAX_LENGTH = 12000;
|
|
6
|
-
const EXECUTION_TIMEOUT = 300000;
|
|
7
|
-
const SERVICE_RETURN_DELAY = 5000;
|
|
8
|
-
|
|
9
5
|
// Git 只读命令白名单
|
|
10
6
|
const GIT_READONLY_COMMANDS = ['show', 'diff', 'log', 'status', 'branch', 'tag', 'ls-files', 'ls-tree', 'rev-parse', 'reflog', 'blame', 'shortlog', 'describe', 'config --get', 'config --list', 'remote', 'ls-remote', 'fetch --dry-run', 'grep'];
|
|
11
7
|
|
|
@@ -86,6 +82,10 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
86
82
|
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
87
83
|
? context.allowedCommands
|
|
88
84
|
: DEFAULT_ALLOWED_COMMANDS;
|
|
85
|
+
|
|
86
|
+
const outputMaxLength = context.outputMaxLength || 12000;
|
|
87
|
+
const executionTimeout = context.executionTimeout || 300000;
|
|
88
|
+
const serviceBootWindow = context.serviceBootWindow || 5000;
|
|
89
89
|
const commands = splitShellCommands(normalizedCommand);
|
|
90
90
|
if (commands.length === 0) {
|
|
91
91
|
return '未找到有效的命令';
|
|
@@ -129,15 +129,15 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
129
129
|
|
|
130
130
|
child.stdout.on('data', (data) => {
|
|
131
131
|
stdout += data.toString();
|
|
132
|
-
if (stdout.length >
|
|
133
|
-
stdout = stdout.slice(0,
|
|
132
|
+
if (stdout.length > outputMaxLength) {
|
|
133
|
+
stdout = stdout.slice(0, outputMaxLength);
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
child.stderr.on('data', (data) => {
|
|
138
138
|
stderr += data.toString();
|
|
139
|
-
if (stderr.length >
|
|
140
|
-
stderr = stderr.slice(0,
|
|
139
|
+
if (stderr.length > outputMaxLength) {
|
|
140
|
+
stderr = stderr.slice(0, outputMaxLength);
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
@@ -169,8 +169,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
169
169
|
const stdOutput = stdout.trim();
|
|
170
170
|
const errOutput = stderr.trim();
|
|
171
171
|
const captured = stdOutput || errOutput ? `\n当前输出:\n${stdOutput}${errOutput ? `\n错误:\n${errOutput}` : ''}` : '\n暂无输出';
|
|
172
|
-
resolve(`已等待 ${
|
|
173
|
-
},
|
|
172
|
+
resolve(`已等待 ${serviceBootWindow / 1000}s,命令已在后台持续运行(PID: ${child.pid})。${captured}`);
|
|
173
|
+
}, serviceBootWindow);
|
|
174
174
|
|
|
175
175
|
const serviceErrorHandler = (error) => {
|
|
176
176
|
clearTimeout(timer);
|
|
@@ -190,8 +190,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
190
190
|
if (settled) return;
|
|
191
191
|
child.kill('SIGTERM');
|
|
192
192
|
cleanup();
|
|
193
|
-
resolve(
|
|
194
|
-
},
|
|
193
|
+
resolve(`命令执行超时 (超过 ${executionTimeout / 1000}s)`);
|
|
194
|
+
}, executionTimeout);
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
};
|
package/src/tools/index.js
CHANGED
|
@@ -3,11 +3,10 @@ const read = require('./read');
|
|
|
3
3
|
const write = require('./write');
|
|
4
4
|
const replace = require('./replace');
|
|
5
5
|
const todos = require('./todos');
|
|
6
|
-
const convert = require('./convert');
|
|
7
6
|
const { createMcpManager } = require('./mcp');
|
|
8
7
|
const { loadSettings } = require('../config');
|
|
9
8
|
|
|
10
|
-
const TOOL_MODULES = [bash, read, write, replace, todos
|
|
9
|
+
const TOOL_MODULES = [bash, read, write, replace, todos];
|
|
11
10
|
|
|
12
11
|
const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
13
12
|
const defaultToolNames = TOOL_MODULES.map((tool) => tool.name);
|
|
@@ -16,7 +15,15 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
|
16
15
|
const enabledMcps = Array.isArray(settings.mcps) ? settings.mcps : [];
|
|
17
16
|
const allowedCommands = settings.allowedCommands;
|
|
18
17
|
|
|
19
|
-
const context = {
|
|
18
|
+
const context = {
|
|
19
|
+
workspaceRoot,
|
|
20
|
+
allowedCommands,
|
|
21
|
+
outputMaxLength: settings.outputMaxLength,
|
|
22
|
+
executionTimeout: settings.executionTimeout,
|
|
23
|
+
serviceBootWindow: settings.serviceBootWindow,
|
|
24
|
+
largeFileLineThreshold: settings.largeFileLineThreshold,
|
|
25
|
+
...options
|
|
26
|
+
};
|
|
20
27
|
|
|
21
28
|
const tools = [];
|
|
22
29
|
const handlers = {};
|
|
@@ -36,7 +43,9 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
|
36
43
|
|
|
37
44
|
TOOL_MODULES.forEach(registerTool);
|
|
38
45
|
|
|
39
|
-
const mcpManager = await createMcpManager(workspaceRoot, enabledMcps
|
|
46
|
+
const mcpManager = await createMcpManager(workspaceRoot, enabledMcps, {
|
|
47
|
+
mcpToolTimeout: settings.mcpToolTimeout
|
|
48
|
+
});
|
|
40
49
|
let mcpConfigPath = null;
|
|
41
50
|
const mcpToolNames = new Set(); // 记录所有 MCP 工具名称
|
|
42
51
|
const enabledMcpNames = []; // 记录启用的 MCP 服务器名称
|
package/src/tools/mcp.js
CHANGED
|
@@ -305,9 +305,10 @@ const formatMcpContent = (content) => {
|
|
|
305
305
|
};
|
|
306
306
|
|
|
307
307
|
class McpManager {
|
|
308
|
-
constructor(workspaceRoot, allowedMcpNames = null) {
|
|
308
|
+
constructor(workspaceRoot, allowedMcpNames = null, options = {}) {
|
|
309
309
|
this.workspaceRoot = workspaceRoot;
|
|
310
310
|
this.allowedMcpNames = Array.isArray(allowedMcpNames) ? allowedMcpNames : null;
|
|
311
|
+
this.mcpToolTimeout = options.mcpToolTimeout || 600000;
|
|
311
312
|
this.clients = [];
|
|
312
313
|
}
|
|
313
314
|
|
|
@@ -407,7 +408,7 @@ class McpManager {
|
|
|
407
408
|
},
|
|
408
409
|
undefined,
|
|
409
410
|
{
|
|
410
|
-
timeout:
|
|
411
|
+
timeout: this.mcpToolTimeout
|
|
411
412
|
}
|
|
412
413
|
);
|
|
413
414
|
const isError = Boolean(result && result.isError);
|
|
@@ -460,11 +461,11 @@ class McpManager {
|
|
|
460
461
|
}
|
|
461
462
|
}
|
|
462
463
|
|
|
463
|
-
const createMcpManager = async (workspaceRoot, allowedMcpNames = null) => {
|
|
464
|
+
const createMcpManager = async (workspaceRoot, allowedMcpNames = null, options = {}) => {
|
|
464
465
|
if (Array.isArray(allowedMcpNames) && allowedMcpNames.length === 0) {
|
|
465
466
|
return null;
|
|
466
467
|
}
|
|
467
|
-
const manager = new McpManager(workspaceRoot, allowedMcpNames);
|
|
468
|
+
const manager = new McpManager(workspaceRoot, allowedMcpNames, options);
|
|
468
469
|
await manager.initialize();
|
|
469
470
|
const tools = manager.getTools();
|
|
470
471
|
if (!tools || tools.length === 0) {
|
package/src/tools/convert.js
DELETED
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
const { spawn } = require('child_process');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const fsPromises = require('fs/promises');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { resolveWorkspacePath } = require('../utils/helpers');
|
|
6
|
-
|
|
7
|
-
const EXECUTION_TIMEOUT = 300000;
|
|
8
|
-
const PYTHON_SCRIPT_ROOT = path.join(__dirname, 'python');
|
|
9
|
-
|
|
10
|
-
const TYPE_META = {
|
|
11
|
-
pdf_to_png: {
|
|
12
|
-
inputType: 'file',
|
|
13
|
-
outputType: 'directory',
|
|
14
|
-
description: '将 PDF 每一页转成 PNG 图片'
|
|
15
|
-
},
|
|
16
|
-
html_to_pptx: {
|
|
17
|
-
inputType: 'list',
|
|
18
|
-
outputType: 'file',
|
|
19
|
-
description: '多个 HTML 截屏后合并为单 PPTX,保持原顺序'
|
|
20
|
-
},
|
|
21
|
-
html_to_png: {
|
|
22
|
-
inputType: 'file',
|
|
23
|
-
outputType: 'file',
|
|
24
|
-
description: '将单个 HTML 文件转换为 PNG 图片'
|
|
25
|
-
},
|
|
26
|
-
pptx_to_pdf: {
|
|
27
|
-
inputType: 'file',
|
|
28
|
-
outputType: 'file',
|
|
29
|
-
description: '通过 LibreOffice 直接将 PPT/PPTX 导出为 PDF'
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const PYTHON_SCRIPTS = {
|
|
34
|
-
pdf_to_png: 'pdf_to_png.py',
|
|
35
|
-
html_to_pptx: 'html_to_pptx.py',
|
|
36
|
-
html_to_png: 'html_to_png.py',
|
|
37
|
-
pptx_to_pdf: 'pptx_to_pdf.py'
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const TYPE_ENUM = Object.keys(TYPE_META);
|
|
41
|
-
|
|
42
|
-
const shellQuote = (value = '') => {
|
|
43
|
-
const str = String(value);
|
|
44
|
-
const escaped = str.replace(/(["$`\\])/g, '\\$1');
|
|
45
|
-
return `"${escaped}"`;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const normalizeListInput = (input) => {
|
|
49
|
-
if (Array.isArray(input)) {
|
|
50
|
-
return input;
|
|
51
|
-
}
|
|
52
|
-
if (typeof input === 'string' && input.trim()) {
|
|
53
|
-
const trimmed = input.trim();
|
|
54
|
-
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
55
|
-
try {
|
|
56
|
-
const parsed = JSON.parse(trimmed);
|
|
57
|
-
if (Array.isArray(parsed)) {
|
|
58
|
-
return parsed;
|
|
59
|
-
}
|
|
60
|
-
} catch (error) {
|
|
61
|
-
// fall through to treating as single path
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return [trimmed];
|
|
65
|
-
}
|
|
66
|
-
return [];
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const ensureOutputLocation = async (targetPath, kind) => {
|
|
70
|
-
if (kind === 'directory') {
|
|
71
|
-
await fsPromises.mkdir(targetPath, { recursive: true });
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
await fsPromises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const runBashCommand = (command, cwd) => new Promise((resolve, reject) => {
|
|
78
|
-
const child = spawn('bash', ['-lc', command], {
|
|
79
|
-
cwd,
|
|
80
|
-
env: process.env,
|
|
81
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
let stdout = '';
|
|
85
|
-
let stderr = '';
|
|
86
|
-
let finished = false;
|
|
87
|
-
|
|
88
|
-
const finalize = (error) => {
|
|
89
|
-
if (finished) return;
|
|
90
|
-
finished = true;
|
|
91
|
-
clearTimeout(timeoutId);
|
|
92
|
-
if (error) {
|
|
93
|
-
reject(error);
|
|
94
|
-
} else {
|
|
95
|
-
resolve(stdout.trim());
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
child.stdout.on('data', (chunk) => {
|
|
100
|
-
stdout += chunk.toString();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
child.stderr.on('data', (chunk) => {
|
|
104
|
-
stderr += chunk.toString();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
child.on('error', (error) => {
|
|
108
|
-
finalize(error);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
child.on('close', (code) => {
|
|
112
|
-
if (code === 0) {
|
|
113
|
-
finalize();
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const message = stderr.trim() || stdout.trim() || `命令执行失败,退出码 ${code}`;
|
|
117
|
-
finalize(new Error(message));
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const timeoutId = setTimeout(() => {
|
|
121
|
-
child.kill('SIGTERM');
|
|
122
|
-
finalize(new Error('命令执行超时 (超过 300s)'));
|
|
123
|
-
}, EXECUTION_TIMEOUT);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const runPythonScript = async (scriptPath, scriptArgs = [], workspaceRoot) => {
|
|
127
|
-
const quotedScript = shellQuote(scriptPath);
|
|
128
|
-
const argsString = scriptArgs.join(' ');
|
|
129
|
-
const appendArgs = argsString ? ` ${argsString}` : '';
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
return await runBashCommand(`python3 ${quotedScript}${appendArgs}`, workspaceRoot);
|
|
133
|
-
} catch (error) {
|
|
134
|
-
const message = error?.message || '';
|
|
135
|
-
if (/python3: command not found/.test(message) || /python3: not found/.test(message)) {
|
|
136
|
-
return runBashCommand(`python ${quotedScript}${appendArgs}`, workspaceRoot);
|
|
137
|
-
}
|
|
138
|
-
throw error;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const resolveInputPaths = (inputType, input, workspaceRoot, typeName) => {
|
|
143
|
-
if (inputType === 'list') {
|
|
144
|
-
const list = normalizeListInput(input);
|
|
145
|
-
if (list.length === 0) {
|
|
146
|
-
throw new Error(`${typeName} 需要至少一个输入路径`);
|
|
147
|
-
}
|
|
148
|
-
const resolvedList = list.map((item, index) => {
|
|
149
|
-
if (typeof item !== 'string' || !item.trim()) {
|
|
150
|
-
throw new Error(`第 ${index + 1} 个输入路径无效`);
|
|
151
|
-
}
|
|
152
|
-
const resolved = resolveWorkspacePath(workspaceRoot, item.trim());
|
|
153
|
-
if (!fs.existsSync(resolved)) {
|
|
154
|
-
throw new Error(`输入文件不存在: ${resolved}`);
|
|
155
|
-
}
|
|
156
|
-
return resolved;
|
|
157
|
-
});
|
|
158
|
-
return { list: resolvedList };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (typeof input !== 'string' || !input.trim()) {
|
|
162
|
-
throw new Error(`${typeName} 需要传入单个输入文件路径`);
|
|
163
|
-
}
|
|
164
|
-
const resolved = resolveWorkspacePath(workspaceRoot, input.trim());
|
|
165
|
-
if (!fs.existsSync(resolved)) {
|
|
166
|
-
throw new Error(`输入文件不存在: ${resolved}`);
|
|
167
|
-
}
|
|
168
|
-
return { file: resolved };
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const formatInputPreview = (input) => {
|
|
172
|
-
if (Array.isArray(input)) {
|
|
173
|
-
return input.join(', ');
|
|
174
|
-
}
|
|
175
|
-
return typeof input === 'string' ? input : '';
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const convertHandler = async ({ type, input, output } = {}, context = {}) => {
|
|
179
|
-
const { workspaceRoot } = context;
|
|
180
|
-
if (!workspaceRoot) {
|
|
181
|
-
return '转换失败: 未找到工作区目录';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!type || typeof type !== 'string') {
|
|
185
|
-
return '转换失败: type 参数无效';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const config = TYPE_META[type];
|
|
189
|
-
if (!config) {
|
|
190
|
-
return `转换失败: 不支持的 type ${type}`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (typeof output !== 'string' || !output.trim()) {
|
|
194
|
-
return '转换失败: output 必须是非空字符串路径';
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
let outputPath;
|
|
198
|
-
try {
|
|
199
|
-
outputPath = resolveWorkspacePath(workspaceRoot, output.trim());
|
|
200
|
-
} catch (error) {
|
|
201
|
-
return `转换失败: ${error.message}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const pptxTypes = new Set(['html_to_pptx']);
|
|
205
|
-
if (pptxTypes.has(type) && path.extname(outputPath).toLowerCase() !== '.pptx') {
|
|
206
|
-
return `转换失败: ${type} 的 output 必须是 .pptx 文件`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
let inputResolution;
|
|
210
|
-
try {
|
|
211
|
-
inputResolution = resolveInputPaths(config.inputType, input, workspaceRoot, type);
|
|
212
|
-
} catch (error) {
|
|
213
|
-
return `转换失败: ${error.message}`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const inputPreview = formatInputPreview(input);
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
await ensureOutputLocation(outputPath, config.outputType);
|
|
220
|
-
|
|
221
|
-
if (PYTHON_SCRIPTS[type]) {
|
|
222
|
-
const scriptPath = path.join(PYTHON_SCRIPT_ROOT, PYTHON_SCRIPTS[type]);
|
|
223
|
-
if (!fs.existsSync(scriptPath)) {
|
|
224
|
-
throw new Error(`找不到 Python 脚本: ${PYTHON_SCRIPTS[type]}`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
let scriptArgs = [];
|
|
228
|
-
if (config.inputType === 'list') {
|
|
229
|
-
scriptArgs = [
|
|
230
|
-
'--inputs',
|
|
231
|
-
...inputResolution.list.map(item => shellQuote(item)),
|
|
232
|
-
'--output',
|
|
233
|
-
shellQuote(outputPath)
|
|
234
|
-
];
|
|
235
|
-
} else {
|
|
236
|
-
scriptArgs = [
|
|
237
|
-
'--input',
|
|
238
|
-
shellQuote(inputResolution.file),
|
|
239
|
-
config.outputType === 'directory' ? '--output-dir' : '--output',
|
|
240
|
-
shellQuote(outputPath)
|
|
241
|
-
];
|
|
242
|
-
}
|
|
243
|
-
await runPythonScript(scriptPath, scriptArgs, workspaceRoot);
|
|
244
|
-
} else {
|
|
245
|
-
throw new Error('未知的转换模式');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return [
|
|
249
|
-
'转换完成',
|
|
250
|
-
`type: ${type}`,
|
|
251
|
-
`input: ${inputPreview}`,
|
|
252
|
-
`output: ${output}`
|
|
253
|
-
].join('\n');
|
|
254
|
-
} catch (error) {
|
|
255
|
-
const message = error?.message || String(error);
|
|
256
|
-
return `转换失败: ${message}`;
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
const schema = {
|
|
261
|
-
type: 'function',
|
|
262
|
-
function: {
|
|
263
|
-
name: 'convert',
|
|
264
|
-
description: [
|
|
265
|
-
`统一文件转换工具。type 支持:${TYPE_ENUM.join('、')}。`,
|
|
266
|
-
'所有路径都是指相对项目根目录的路径,例如:src/index.md、README.md、src/outputs等。'
|
|
267
|
-
].join(' '),
|
|
268
|
-
parameters: {
|
|
269
|
-
type: 'object',
|
|
270
|
-
properties: {
|
|
271
|
-
type: {
|
|
272
|
-
type: 'string',
|
|
273
|
-
enum: TYPE_ENUM,
|
|
274
|
-
description: `转换类型,可选值: ${TYPE_ENUM.join(', ')}`
|
|
275
|
-
},
|
|
276
|
-
input: {
|
|
277
|
-
description: '输入路径:当 inputType 为 list(如 html_to_pptx)时为路径列表,否则为单文件路径。',
|
|
278
|
-
oneOf: [
|
|
279
|
-
{ type: 'string' },
|
|
280
|
-
{ type: 'array', items: { type: 'string' } }
|
|
281
|
-
]
|
|
282
|
-
},
|
|
283
|
-
output: {
|
|
284
|
-
type: 'string',
|
|
285
|
-
description: '输出路径:PDF->PNG 时为目录路径,其余类型为单文件路径。'
|
|
286
|
-
}
|
|
287
|
-
},
|
|
288
|
-
required: ['type', 'input', 'output']
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
module.exports = {
|
|
294
|
-
name: 'convert',
|
|
295
|
-
schema,
|
|
296
|
-
handler: convertHandler
|
|
297
|
-
};
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""Convert a single HTML file to PNG via Playwright screenshot."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from playwright.sync_api import sync_playwright
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
MIN_WIDTH = 320
|
|
12
|
-
MIN_HEIGHT = 240
|
|
13
|
-
DEVICE_SCALE_FACTOR = 4
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _find_target_element(page):
|
|
17
|
-
body = page.query_selector('body')
|
|
18
|
-
if body is None:
|
|
19
|
-
raise ValueError('未找到 body 元素')
|
|
20
|
-
|
|
21
|
-
top_level_elements = body.query_selector_all(':scope > *')
|
|
22
|
-
if not top_level_elements:
|
|
23
|
-
raise ValueError('body 中未找到可截图元素')
|
|
24
|
-
|
|
25
|
-
# 单个最外层节点时直接使用该节点,否则使用整个 body
|
|
26
|
-
return top_level_elements[0] if len(top_level_elements) == 1 else body
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _measure_element(element):
|
|
30
|
-
size = element.evaluate(
|
|
31
|
-
"""
|
|
32
|
-
(el) => {
|
|
33
|
-
const rect = el.getBoundingClientRect();
|
|
34
|
-
const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
|
|
35
|
-
const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
|
|
36
|
-
return { width, height };
|
|
37
|
-
}
|
|
38
|
-
"""
|
|
39
|
-
)
|
|
40
|
-
width = max(1, int(size.get('width') or 0))
|
|
41
|
-
height = max(1, int(size.get('height') or 0))
|
|
42
|
-
return width, height
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def convert_html_to_png(input_file: str | Path, output_file: str | Path, wait_ms: int = 1500) -> Path:
|
|
46
|
-
"""Convert a single HTML file to PNG image."""
|
|
47
|
-
html_path = Path(input_file).expanduser().resolve()
|
|
48
|
-
if not html_path.exists():
|
|
49
|
-
raise FileNotFoundError(f'HTML 文件不存在: {html_path}')
|
|
50
|
-
|
|
51
|
-
output_path = Path(output_file).expanduser().resolve()
|
|
52
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
-
|
|
54
|
-
with sync_playwright() as playwright:
|
|
55
|
-
browser = playwright.chromium.launch(headless=True)
|
|
56
|
-
context = browser.new_context(
|
|
57
|
-
viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
|
|
58
|
-
device_scale_factor=DEVICE_SCALE_FACTOR
|
|
59
|
-
)
|
|
60
|
-
page = context.new_page()
|
|
61
|
-
try:
|
|
62
|
-
file_url = html_path.as_uri()
|
|
63
|
-
page.goto(file_url)
|
|
64
|
-
page.wait_for_timeout(wait_ms)
|
|
65
|
-
|
|
66
|
-
target = _find_target_element(page)
|
|
67
|
-
element_width, element_height = _measure_element(target)
|
|
68
|
-
viewport_width = max(MIN_WIDTH, element_width)
|
|
69
|
-
viewport_height = max(MIN_HEIGHT, element_height)
|
|
70
|
-
|
|
71
|
-
page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
|
|
72
|
-
page.wait_for_timeout(200)
|
|
73
|
-
target.screenshot(path=str(output_path))
|
|
74
|
-
finally:
|
|
75
|
-
context.close()
|
|
76
|
-
browser.close()
|
|
77
|
-
|
|
78
|
-
return output_path
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _parse_args() -> argparse.Namespace:
|
|
82
|
-
parser = argparse.ArgumentParser(description='将 HTML 文件转换为 PNG 图片。')
|
|
83
|
-
parser.add_argument('--input', required=True, help='HTML 文件路径')
|
|
84
|
-
parser.add_argument('--output', required=True, help='输出 PNG 文件路径')
|
|
85
|
-
parser.add_argument('--wait', type=int, default=1500, help='页面加载等待时间,单位毫秒,默认 1500')
|
|
86
|
-
return parser.parse_args()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def main() -> None:
|
|
90
|
-
args = _parse_args()
|
|
91
|
-
try:
|
|
92
|
-
result = convert_html_to_png(args.input, args.output, wait_ms=args.wait)
|
|
93
|
-
print(f'已生成 PNG: {result}')
|
|
94
|
-
except Exception as error:
|
|
95
|
-
print(f'转换失败: {error}', file=sys.stderr)
|
|
96
|
-
sys.exit(1)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if __name__ == '__main__':
|
|
100
|
-
main()
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
"""Convert multiple HTML files into a single PPTX via Playwright screenshots."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import sys
|
|
6
|
-
import tempfile
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Iterable, List
|
|
9
|
-
|
|
10
|
-
from PIL import Image
|
|
11
|
-
from playwright.sync_api import sync_playwright
|
|
12
|
-
from pptx import Presentation
|
|
13
|
-
from pptx.util import Inches
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SLIDE_SIZE_43 = (Inches(10), Inches(7.5)) # 4:3
|
|
17
|
-
SLIDE_SIZE_169 = (Inches(13.3333), Inches(7.5)) # 16:9
|
|
18
|
-
MIN_WIDTH = 320
|
|
19
|
-
MIN_HEIGHT = 240
|
|
20
|
-
DEVICE_SCALE_FACTOR = 4
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _find_target_element(page):
|
|
24
|
-
body = page.query_selector('body')
|
|
25
|
-
if body is None:
|
|
26
|
-
raise ValueError('未找到 body 元素')
|
|
27
|
-
|
|
28
|
-
top_level_elements = body.query_selector_all(':scope > *')
|
|
29
|
-
if not top_level_elements:
|
|
30
|
-
raise ValueError('body 中未找到可截图元素')
|
|
31
|
-
|
|
32
|
-
# 单个最外层节点时直接使用该节点,否则使用整个 body
|
|
33
|
-
return top_level_elements[0] if len(top_level_elements) == 1 else body
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _measure_element(element):
|
|
37
|
-
size = element.evaluate(
|
|
38
|
-
"""
|
|
39
|
-
(el) => {
|
|
40
|
-
const rect = el.getBoundingClientRect();
|
|
41
|
-
const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
|
|
42
|
-
const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
|
|
43
|
-
return { width, height };
|
|
44
|
-
}
|
|
45
|
-
"""
|
|
46
|
-
)
|
|
47
|
-
width = max(1, int(size.get('width') or 0))
|
|
48
|
-
height = max(1, int(size.get('height') or 0))
|
|
49
|
-
return width, height
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _choose_slide_size(aspect_ratios: Iterable[float]):
|
|
53
|
-
ratios = [ratio for ratio in aspect_ratios if ratio > 0]
|
|
54
|
-
if not ratios:
|
|
55
|
-
return SLIDE_SIZE_43
|
|
56
|
-
average_ratio = sum(ratios) / len(ratios)
|
|
57
|
-
diff_43 = abs(average_ratio - (4 / 3))
|
|
58
|
-
diff_169 = abs(average_ratio - (16 / 9))
|
|
59
|
-
return SLIDE_SIZE_43 if diff_43 <= diff_169 else SLIDE_SIZE_169
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def capture_html_to_image(page, html_path: Path, target_path: Path, wait_ms: int) -> float:
|
|
63
|
-
file_url = html_path.as_uri()
|
|
64
|
-
page.goto(file_url)
|
|
65
|
-
page.wait_for_timeout(wait_ms)
|
|
66
|
-
target = _find_target_element(page)
|
|
67
|
-
element_width, element_height = _measure_element(target)
|
|
68
|
-
viewport_width = max(MIN_WIDTH, element_width)
|
|
69
|
-
viewport_height = max(MIN_HEIGHT, element_height)
|
|
70
|
-
page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
|
|
71
|
-
page.wait_for_timeout(200)
|
|
72
|
-
target.screenshot(path=str(target_path))
|
|
73
|
-
return element_width / element_height if element_height else 1.0
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def build_presentation(images: Iterable[Path], output_file: Path, slide_size) -> Path:
|
|
77
|
-
slide_width, slide_height = slide_size
|
|
78
|
-
presentation = Presentation()
|
|
79
|
-
presentation.slide_width = slide_width
|
|
80
|
-
presentation.slide_height = slide_height
|
|
81
|
-
|
|
82
|
-
# Remove default slide if present
|
|
83
|
-
while len(presentation.slides) > 0:
|
|
84
|
-
slide_id = presentation.slides._sldIdLst[0].rId
|
|
85
|
-
presentation.part.drop_rel(slide_id)
|
|
86
|
-
del presentation.slides._sldIdLst[0]
|
|
87
|
-
|
|
88
|
-
layout = presentation.slide_layouts[6]
|
|
89
|
-
for image_path in images:
|
|
90
|
-
with Image.open(image_path) as image:
|
|
91
|
-
image_ratio = image.width / image.height if image.height else 1.0
|
|
92
|
-
slide_ratio = slide_width / slide_height if slide_height else image_ratio
|
|
93
|
-
if slide_ratio >= image_ratio:
|
|
94
|
-
target_height = slide_height
|
|
95
|
-
target_width = int(target_height * image_ratio)
|
|
96
|
-
else:
|
|
97
|
-
target_width = slide_width
|
|
98
|
-
target_height = int(target_width / image_ratio)
|
|
99
|
-
left = max(0, (slide_width - target_width) // 2)
|
|
100
|
-
top = max(0, (slide_height - target_height) // 2)
|
|
101
|
-
slide = presentation.slides.add_slide(layout)
|
|
102
|
-
slide.shapes.add_picture(str(image_path), left, top, width=target_width, height=target_height)
|
|
103
|
-
|
|
104
|
-
presentation.save(str(output_file))
|
|
105
|
-
return output_file
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def convert_html_list_to_pptx(html_files: Iterable[str | Path], output_file: str | Path, wait_ms: int = 1500) -> Path:
|
|
109
|
-
html_paths = [Path(item).expanduser().resolve() for item in html_files]
|
|
110
|
-
if not html_paths:
|
|
111
|
-
raise ValueError('请提供至少一个 HTML 文件')
|
|
112
|
-
for path_item in html_paths:
|
|
113
|
-
if not path_item.exists():
|
|
114
|
-
raise FileNotFoundError(f'HTML 文件不存在: {path_item}')
|
|
115
|
-
|
|
116
|
-
output_path = Path(output_file).expanduser().resolve()
|
|
117
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
-
|
|
119
|
-
screenshots: List[Path] = []
|
|
120
|
-
aspect_ratios: List[float] = []
|
|
121
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
122
|
-
temp_root = Path(temp_dir)
|
|
123
|
-
with sync_playwright() as playwright:
|
|
124
|
-
browser = playwright.chromium.launch(headless=True)
|
|
125
|
-
context = browser.new_context(
|
|
126
|
-
viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
|
|
127
|
-
device_scale_factor=DEVICE_SCALE_FACTOR
|
|
128
|
-
)
|
|
129
|
-
page = context.new_page()
|
|
130
|
-
try:
|
|
131
|
-
for index, html_path in enumerate(html_paths, start=1):
|
|
132
|
-
target_file = temp_root / f'slide_{index:04d}.png'
|
|
133
|
-
ratio = capture_html_to_image(page, html_path, target_file, wait_ms)
|
|
134
|
-
screenshots.append(target_file)
|
|
135
|
-
aspect_ratios.append(ratio)
|
|
136
|
-
finally:
|
|
137
|
-
context.close()
|
|
138
|
-
browser.close()
|
|
139
|
-
slide_size = _choose_slide_size(aspect_ratios)
|
|
140
|
-
build_presentation(screenshots, output_path, slide_size)
|
|
141
|
-
return output_path
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _parse_args() -> argparse.Namespace:
|
|
145
|
-
parser = argparse.ArgumentParser(description='将多个 HTML 文件合并到单个 PPTX 中。')
|
|
146
|
-
parser.add_argument('--inputs', nargs='+', required=True, help='HTML 文件路径列表')
|
|
147
|
-
parser.add_argument('--output', required=True, help='输出 PPTX 文件路径')
|
|
148
|
-
parser.add_argument('--wait', type=int, default=1500, help='每个页面加载等待时间,单位毫秒,默认 1500')
|
|
149
|
-
return parser.parse_args()
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def main() -> None:
|
|
153
|
-
args = _parse_args()
|
|
154
|
-
try:
|
|
155
|
-
result = convert_html_list_to_pptx(args.inputs, args.output, wait_ms=args.wait)
|
|
156
|
-
print(f'已生成 PPTX: {result}')
|
|
157
|
-
except Exception as error:
|
|
158
|
-
print(f'转换失败: {error}', file=sys.stderr)
|
|
159
|
-
sys.exit(1)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if __name__ == '__main__':
|
|
163
|
-
main()
|