mcp-md-word-oss 1.0.1
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/markdownToWord.js +130 -0
- package/package.json +34 -0
- package/server.js +343 -0
- package/upload_md_to_oss.py +421 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → Word(pandoc),中间文件与 Mermaid 产出均在 workDir 下。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { randomUUID } = require('crypto');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
function extractMermaidBlocks(mdContent) {
|
|
11
|
+
const blocks = [];
|
|
12
|
+
const pattern = /```mermaid\s*\n([\s\S]*?)\n```/g;
|
|
13
|
+
let match;
|
|
14
|
+
let index = 0;
|
|
15
|
+
while ((match = pattern.exec(mdContent)) !== null) {
|
|
16
|
+
blocks.push({
|
|
17
|
+
index,
|
|
18
|
+
code: match[1].trim(),
|
|
19
|
+
placeholder: `<!-- MERMAID_PLACEHOLDER_${index} -->`,
|
|
20
|
+
});
|
|
21
|
+
index += 1;
|
|
22
|
+
}
|
|
23
|
+
return blocks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(string) {
|
|
27
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateMermaidImage(mermaidCode, outputPath) {
|
|
31
|
+
try {
|
|
32
|
+
const tempDir = path.dirname(outputPath);
|
|
33
|
+
const tempMmFile = path.join(tempDir, `temp_diagram_${Date.now()}.mmd`);
|
|
34
|
+
fs.writeFileSync(tempMmFile, mermaidCode, 'utf-8');
|
|
35
|
+
execFileSync(
|
|
36
|
+
'npx',
|
|
37
|
+
['-y', '@mermaid-js/mermaid-cli', '-i', tempMmFile, '-o', outputPath, '-b', 'white'],
|
|
38
|
+
{ timeout: 60000, encoding: 'utf-8', stdio: ['ignore', 'ignore', 'pipe'], maxBuffer: 2 * 1024 * 1024 }
|
|
39
|
+
);
|
|
40
|
+
if (fs.existsSync(tempMmFile)) fs.unlinkSync(tempMmFile);
|
|
41
|
+
return fs.existsSync(outputPath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Mermaid图片生成失败:', error);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{ mdPath: string, docxPath: string, workDir: string, skipMermaid?: boolean }} args
|
|
50
|
+
* @returns {Promise<{ docxPath: string, usedPandoc: boolean }>}
|
|
51
|
+
*/
|
|
52
|
+
async function markdownToWord(args) {
|
|
53
|
+
const { mdPath, docxPath, workDir, skipMermaid = false } = args;
|
|
54
|
+
|
|
55
|
+
if (!mdPath) throw new Error('缺少必需参数: mdPath');
|
|
56
|
+
if (!docxPath) throw new Error('缺少必需参数: docxPath');
|
|
57
|
+
if (!workDir) throw new Error('缺少必需参数: workDir');
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(mdPath)) throw new Error(`文件不存在: ${mdPath}`);
|
|
60
|
+
if (!mdPath.toLowerCase().endsWith('.md')) throw new Error('仅支持.md格式');
|
|
61
|
+
if (!docxPath.toLowerCase().endsWith('.docx')) throw new Error('仅支持.docx格式');
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(workDir)) {
|
|
64
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let mdContent = fs.readFileSync(mdPath, 'utf-8');
|
|
68
|
+
const outputDir = workDir;
|
|
69
|
+
const mermaidImages = {};
|
|
70
|
+
|
|
71
|
+
if (!skipMermaid) {
|
|
72
|
+
const mermaidBlocks = extractMermaidBlocks(mdContent);
|
|
73
|
+
for (const block of mermaidBlocks) {
|
|
74
|
+
const imageFilename = `mermaid_diagram_${block.index}.png`;
|
|
75
|
+
const imagePath = path.join(outputDir, imageFilename);
|
|
76
|
+
const mermaidBlockPattern = new RegExp(
|
|
77
|
+
`\\\`\\\`\\\`mermaid\\s*\\n${escapeRegExp(block.code)}\\n\\\`\\\`\\\``,
|
|
78
|
+
'g'
|
|
79
|
+
);
|
|
80
|
+
mdContent = mdContent.replace(mermaidBlockPattern, block.placeholder);
|
|
81
|
+
const success = generateMermaidImage(block.code, imagePath);
|
|
82
|
+
if (success) {
|
|
83
|
+
mermaidImages[block.placeholder] = imagePath;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let wordContent = mdContent;
|
|
89
|
+
wordContent = wordContent.replace(/^### (.+)$/gm, '### $1');
|
|
90
|
+
wordContent = wordContent.replace(/^## (.+)$/gm, '## $1');
|
|
91
|
+
wordContent = wordContent.replace(/^# (.+)$/gm, '# $1');
|
|
92
|
+
wordContent = wordContent.replace(/```(\w+)?\n([\s\S]*?)```/g, '\n$2\n');
|
|
93
|
+
wordContent = wordContent.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
94
|
+
wordContent = wordContent.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
95
|
+
wordContent = wordContent.replace(/\*([^*]+)\*/g, '$1');
|
|
96
|
+
|
|
97
|
+
for (const placeholder of Object.keys(mermaidImages)) {
|
|
98
|
+
wordContent = wordContent.replace(
|
|
99
|
+
placeholder,
|
|
100
|
+
`[Mermaid图表图片: ${path.basename(mermaidImages[placeholder])}]`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tempTxtPath = path.join(workDir, `pandoc-${randomUUID()}.txt`);
|
|
105
|
+
fs.writeFileSync(tempTxtPath, wordContent, 'utf-8');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
execFileSync('pandoc', [tempTxtPath, '-o', docxPath], {
|
|
109
|
+
encoding: 'utf-8',
|
|
110
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
111
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
112
|
+
});
|
|
113
|
+
return { docxPath, usedPandoc: true };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const stderr = e.stderr != null ? String(e.stderr).trim() : '';
|
|
116
|
+
const hint =
|
|
117
|
+
e.code === 'ENOENT'
|
|
118
|
+
? '未在 PATH 中找到 pandoc,请安装并配置 PATH。'
|
|
119
|
+
: stderr || e.message || '未知错误';
|
|
120
|
+
throw new Error(`无法生成 .docx(pandoc):${hint}`);
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
if (fs.existsSync(tempTxtPath)) fs.unlinkSync(tempTxtPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { markdownToWord };
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-md-word-oss",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "MCP: JSON payload with markdown → OSS MD + pandoc Word → OSS DOCX (tmp dir: DOC_PIPELINE_TMP_DIR)",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-md-word-oss": "server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.js",
|
|
12
|
+
"markdownToWord.js",
|
|
13
|
+
"upload_md_to_oss.py"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node server.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"oss",
|
|
21
|
+
"markdown",
|
|
22
|
+
"docx",
|
|
23
|
+
"pandoc"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
28
|
+
"zod": "^3.23.8",
|
|
29
|
+
"zod-to-json-schema": "^3.23.5"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 合一 MCP:JSON payload(含 markdown)→ 写临时目录 → 上传 MD 到 OSS → pandoc 转 Word → 上传 DOCX → 返回 mdUrl / wordUrl
|
|
4
|
+
*
|
|
5
|
+
* 环境变量(OSS,与 mcp-oss-upload 一致):
|
|
6
|
+
* OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET_NAME, OSS_ENDPOINT
|
|
7
|
+
* 可选:OSS_MAX_MARKDOWN_CONTENT_BYTES(默认约 20MB)
|
|
8
|
+
*
|
|
9
|
+
* 临时目录:
|
|
10
|
+
* DOC_PIPELINE_TMP_DIR — 指定本 MCP 工作临时目录(每次任务在其子目录下写入 .md/.docx/mermaid 等);未设置则用 os.tmpdir()
|
|
11
|
+
* Python:
|
|
12
|
+
* DOC_PIPELINE_PYTHON 或 PYTHON — 可选,指定 python 可执行文件路径(默认 Windows 用 python,其它平台 python3)
|
|
13
|
+
* Mermaid(可选):
|
|
14
|
+
* DOC_PIPELINE_SKIP_MERMAID=1 — 跳过 Mermaid 转图片(无网/无 npx 环境可避免因拉包超时卡住)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
18
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
19
|
+
const {
|
|
20
|
+
CallToolRequestSchema,
|
|
21
|
+
ListToolsRequestSchema,
|
|
22
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
23
|
+
const { z } = require('zod');
|
|
24
|
+
const { zodToJsonSchema } = require('zod-to-json-schema');
|
|
25
|
+
const { execFile } = require('child_process');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const { randomBytes } = require('crypto');
|
|
30
|
+
const { markdownToWord } = require('./markdownToWord.js');
|
|
31
|
+
|
|
32
|
+
const EXEC_MAX_BUFFER = 10 * 1024 * 1024;
|
|
33
|
+
const DEFAULT_MAX_MARKDOWN_BYTES = 20 * 1024 * 1024;
|
|
34
|
+
let MAX_MARKDOWN_CONTENT_BYTES = parseInt(
|
|
35
|
+
process.env.OSS_MAX_MARKDOWN_CONTENT_BYTES || String(DEFAULT_MAX_MARKDOWN_BYTES),
|
|
36
|
+
10
|
|
37
|
+
);
|
|
38
|
+
if (!Number.isFinite(MAX_MARKDOWN_CONTENT_BYTES) || MAX_MARKDOWN_CONTENT_BYTES <= 0) {
|
|
39
|
+
MAX_MARKDOWN_CONTENT_BYTES = DEFAULT_MAX_MARKDOWN_BYTES;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getPipelineTmpRoot() {
|
|
43
|
+
const raw = (process.env.DOC_PIPELINE_TMP_DIR || '').trim();
|
|
44
|
+
if (raw) {
|
|
45
|
+
const abs = path.resolve(raw);
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(abs)) {
|
|
48
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
fs.accessSync(abs, fs.constants.W_OK);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`DOC_PIPELINE_TMP_DIR 不可用(创建/写入失败): ${abs} — ${e.message}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return abs;
|
|
57
|
+
}
|
|
58
|
+
return os.tmpdir();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getPythonExecutable() {
|
|
62
|
+
const fromEnv = (process.env.DOC_PIPELINE_PYTHON || process.env.PYTHON || '').trim();
|
|
63
|
+
if (fromEnv) return fromEnv;
|
|
64
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function silenceBrokenPipe(stream) {
|
|
68
|
+
stream.on('error', (err) => {
|
|
69
|
+
if (err && err.code === 'EPIPE') return;
|
|
70
|
+
console.error('[md-word-oss] stream error:', err);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
silenceBrokenPipe(process.stdout);
|
|
74
|
+
silenceBrokenPipe(process.stderr);
|
|
75
|
+
|
|
76
|
+
process.on('unhandledRejection', (reason) => {
|
|
77
|
+
console.error('[md-word-oss] unhandledRejection:', reason);
|
|
78
|
+
});
|
|
79
|
+
process.on('uncaughtException', (err) => {
|
|
80
|
+
console.error('[md-word-oss] uncaughtException:', err);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function assertMarkdownContentSize(content) {
|
|
85
|
+
const n = Buffer.byteLength(content, 'utf8');
|
|
86
|
+
if (n > MAX_MARKDOWN_CONTENT_BYTES) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Markdown 超过 OSS_MAX_MARKDOWN_CONTENT_BYTES(${n} 字节,上限 ${MAX_MARKDOWN_CONTENT_BYTES})`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeMdFilename(name) {
|
|
94
|
+
const raw =
|
|
95
|
+
name != null && String(name).trim() !== ''
|
|
96
|
+
? String(name).trim()
|
|
97
|
+
: 'document.md';
|
|
98
|
+
const base = path.basename(raw) || 'document.md';
|
|
99
|
+
const withExt = base.toLowerCase().endsWith('.md') ? base : `${base}.md`;
|
|
100
|
+
return withExt.replace(/[/\\]/g, '-');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PYTHON_SCRIPT = path.join(__dirname, 'upload_md_to_oss.py');
|
|
104
|
+
|
|
105
|
+
async function uploadFileWithPython(filePath, objectKey = null) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const args = [PYTHON_SCRIPT, filePath, '--json'];
|
|
108
|
+
if (objectKey) {
|
|
109
|
+
args.push('--object-key', objectKey);
|
|
110
|
+
}
|
|
111
|
+
const pythonExecutable = getPythonExecutable();
|
|
112
|
+
execFile(
|
|
113
|
+
pythonExecutable,
|
|
114
|
+
args,
|
|
115
|
+
{ encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER },
|
|
116
|
+
(error, stdout, stderr) => {
|
|
117
|
+
if (error) {
|
|
118
|
+
if (error.code === 'ENOENT') {
|
|
119
|
+
reject(
|
|
120
|
+
new Error(
|
|
121
|
+
`找不到 Python 可执行文件「${pythonExecutable}」。请安装 Python 并加入 PATH,或设置环境变量 DOC_PIPELINE_PYTHON(或 PYTHON)为完整路径。`
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (stderr) {
|
|
127
|
+
try {
|
|
128
|
+
const errorResult = JSON.parse(stderr);
|
|
129
|
+
resolve(errorResult);
|
|
130
|
+
return;
|
|
131
|
+
} catch {
|
|
132
|
+
// fall through
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
reject(new Error(`执行 Python 上传失败: ${error.message}\nstderr: ${stderr}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
resolve(JSON.parse(stdout));
|
|
140
|
+
} catch (e) {
|
|
141
|
+
reject(new Error(`解析 Python 输出失败: ${stdout}`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function uploadDocxFileWithPython(filePath, objectKey = null) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const args = [PYTHON_SCRIPT, filePath, '--docx', '--json'];
|
|
151
|
+
if (objectKey) {
|
|
152
|
+
args.push('--object-key', objectKey);
|
|
153
|
+
}
|
|
154
|
+
const pythonExecutable = getPythonExecutable();
|
|
155
|
+
execFile(
|
|
156
|
+
pythonExecutable,
|
|
157
|
+
args,
|
|
158
|
+
{ encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER },
|
|
159
|
+
(error, stdout, stderr) => {
|
|
160
|
+
if (error) {
|
|
161
|
+
if (error.code === 'ENOENT') {
|
|
162
|
+
reject(
|
|
163
|
+
new Error(
|
|
164
|
+
`找不到 Python 可执行文件「${pythonExecutable}」。请安装 Python 并加入 PATH,或设置 DOC_PIPELINE_PYTHON(或 PYTHON)为完整路径。`
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (stderr) {
|
|
170
|
+
try {
|
|
171
|
+
const errorResult = JSON.parse(stderr);
|
|
172
|
+
resolve(errorResult);
|
|
173
|
+
return;
|
|
174
|
+
} catch {
|
|
175
|
+
// fall through
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
reject(new Error(`执行 Python 上传 docx 失败: ${error.message}\nstderr: ${stderr}`));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
resolve(JSON.parse(stdout));
|
|
183
|
+
} catch (e) {
|
|
184
|
+
reject(new Error(`解析 Python 输出失败: ${stdout}`));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const PublishPayloadSchema = z.object({
|
|
192
|
+
payload: z
|
|
193
|
+
.string()
|
|
194
|
+
.describe(
|
|
195
|
+
'JSON 字符串,必须包含 markdown;可选 filename(默认 document.md)、mdObjectKey、docxObjectKey(旧版 md_object_key / docx_object_key 仍兼容)'
|
|
196
|
+
),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function pickOptionalKey(obj, camelKey, legacyKey) {
|
|
200
|
+
const a = obj[camelKey];
|
|
201
|
+
if (a != null && String(a).trim() !== '') return String(a).trim();
|
|
202
|
+
const b = obj[legacyKey];
|
|
203
|
+
if (b != null && String(b).trim() !== '') return String(b).trim();
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const server = new Server(
|
|
208
|
+
{
|
|
209
|
+
name: 'md-word-oss',
|
|
210
|
+
version: '1.0.1',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
capabilities: {
|
|
214
|
+
tools: {},
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
220
|
+
return {
|
|
221
|
+
tools: [
|
|
222
|
+
{
|
|
223
|
+
name: 'publishMarkdownOssWord',
|
|
224
|
+
description:
|
|
225
|
+
'解析 JSON 字符串(含 markdown 正文),写入临时目录(DOC_PIPELINE_TMP_DIR),上传 .md 到 OSS,pandoc 生成 .docx 并上传,返回 mdUrl 与 wordUrl。',
|
|
226
|
+
inputSchema: zodToJsonSchema(PublishPayloadSchema),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
233
|
+
const { name, arguments: args } = request.params;
|
|
234
|
+
|
|
235
|
+
if (name !== 'publishMarkdownOssWord') {
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let jobDir = null;
|
|
243
|
+
try {
|
|
244
|
+
const { payload } = PublishPayloadSchema.parse(args ?? {});
|
|
245
|
+
|
|
246
|
+
let inner;
|
|
247
|
+
try {
|
|
248
|
+
inner = JSON.parse(payload);
|
|
249
|
+
} catch {
|
|
250
|
+
throw new Error('payload 不是合法 JSON');
|
|
251
|
+
}
|
|
252
|
+
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) {
|
|
253
|
+
throw new Error('payload 解析后必须是 JSON 对象(不能是数组)');
|
|
254
|
+
}
|
|
255
|
+
if (typeof inner.markdown !== 'string') {
|
|
256
|
+
throw new Error('JSON 内必须包含字符串字段 markdown');
|
|
257
|
+
}
|
|
258
|
+
const markdown = inner.markdown;
|
|
259
|
+
if (!markdown.trim()) {
|
|
260
|
+
throw new Error('markdown 不能为空');
|
|
261
|
+
}
|
|
262
|
+
assertMarkdownContentSize(markdown);
|
|
263
|
+
|
|
264
|
+
const tmpRoot = getPipelineTmpRoot();
|
|
265
|
+
const jobId = `${Date.now()}-${randomBytes(8).toString('hex')}`;
|
|
266
|
+
jobDir = path.join(tmpRoot, `doc-pipeline-${jobId}`);
|
|
267
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
268
|
+
|
|
269
|
+
const mdName = safeMdFilename(inner.filename);
|
|
270
|
+
const mdPath = path.join(jobDir, mdName);
|
|
271
|
+
fs.writeFileSync(mdPath, markdown, 'utf8');
|
|
272
|
+
|
|
273
|
+
const mdKey = pickOptionalKey(inner, 'mdObjectKey', 'md_object_key');
|
|
274
|
+
const mdUpload = await uploadFileWithPython(mdPath, mdKey);
|
|
275
|
+
if (!mdUpload.success) {
|
|
276
|
+
throw new Error(mdUpload.error || '上传 Markdown 到 OSS 失败');
|
|
277
|
+
}
|
|
278
|
+
if (!mdUpload.url) {
|
|
279
|
+
throw new Error('OSS 上传返回异常:缺少 url 字段');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const docxBase = path.basename(mdName, '.md') + '.docx';
|
|
283
|
+
const docxPath = path.join(jobDir, docxBase);
|
|
284
|
+
const skipMermaid = /^1|true|yes$/i.test(
|
|
285
|
+
String(process.env.DOC_PIPELINE_SKIP_MERMAID || '').trim()
|
|
286
|
+
);
|
|
287
|
+
await markdownToWord({
|
|
288
|
+
mdPath,
|
|
289
|
+
docxPath,
|
|
290
|
+
workDir: jobDir,
|
|
291
|
+
skipMermaid,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const docxKey = pickOptionalKey(inner, 'docxObjectKey', 'docx_object_key');
|
|
295
|
+
const docxUpload = await uploadDocxFileWithPython(docxPath, docxKey);
|
|
296
|
+
if (!docxUpload.success) {
|
|
297
|
+
throw new Error(docxUpload.error || '上传 Word 到 OSS 失败');
|
|
298
|
+
}
|
|
299
|
+
if (!docxUpload.url) {
|
|
300
|
+
throw new Error('OSS 上传返回异常:缺少 url 字段');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const body = {
|
|
304
|
+
status: 'success',
|
|
305
|
+
mdUrl: mdUpload.url,
|
|
306
|
+
wordUrl: docxUpload.url,
|
|
307
|
+
mdObjectKey: mdUpload.object_key,
|
|
308
|
+
wordObjectKey: docxUpload.object_key,
|
|
309
|
+
bucket: mdUpload.bucket,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: 'text', text: JSON.stringify(body, null, 2) }],
|
|
314
|
+
};
|
|
315
|
+
} catch (e) {
|
|
316
|
+
const msg =
|
|
317
|
+
e instanceof z.ZodError
|
|
318
|
+
? e.errors.map((x) => `${x.path.join('.')}: ${x.message}`).join('; ')
|
|
319
|
+
: e.message || String(e);
|
|
320
|
+
return {
|
|
321
|
+
content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: msg }) }],
|
|
322
|
+
isError: true,
|
|
323
|
+
};
|
|
324
|
+
} finally {
|
|
325
|
+
if (jobDir && fs.existsSync(jobDir)) {
|
|
326
|
+
try {
|
|
327
|
+
fs.rmSync(jobDir, { recursive: true, force: true });
|
|
328
|
+
} catch {
|
|
329
|
+
// ignore
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
async function main() {
|
|
336
|
+
const transport = new StdioServerTransport();
|
|
337
|
+
await server.connect(transport);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
main().catch((err) => {
|
|
341
|
+
console.error('[md-word-oss] fatal:', err);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
上传Markdown文件到阿里云OSS
|
|
5
|
+
通过环境变量 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET_NAME、OSS_ENDPOINT 配置。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import base64
|
|
11
|
+
import datetime
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_oss_config():
|
|
21
|
+
"""仅从环境变量读取(请在 MetaMCP / 宿主机的 MCP 配置里设置 OSS_*)。"""
|
|
22
|
+
return {
|
|
23
|
+
'access_key_id': os.environ.get('OSS_ACCESS_KEY_ID', ''),
|
|
24
|
+
'access_key_secret': os.environ.get('OSS_ACCESS_KEY_SECRET', ''),
|
|
25
|
+
'endpoint': os.environ.get('OSS_ENDPOINT', 'oss-cn-beijing.aliyuncs.com'),
|
|
26
|
+
'bucket_name': os.environ.get('OSS_BUCKET_NAME', ''),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_and_validate_oss_config(config):
|
|
31
|
+
"""
|
|
32
|
+
规范化 endpoint / bucket,并在非法时返回错误 dict;合法则返回 None(会原地改写 config)。
|
|
33
|
+
若 bucket 为空,URL 会变成 https://.<endpoint>/...,urllib 解析主机名会报 idna label empty。
|
|
34
|
+
"""
|
|
35
|
+
bn = (config.get('bucket_name') or '').strip()
|
|
36
|
+
ak = (config.get('access_key_id') or '').strip()
|
|
37
|
+
sk = (config.get('access_key_secret') or '').strip()
|
|
38
|
+
ep = (config.get('endpoint') or '').strip()
|
|
39
|
+
for prefix in ('https://', 'http://'):
|
|
40
|
+
if ep.lower().startswith(prefix):
|
|
41
|
+
ep = ep[len(prefix) :]
|
|
42
|
+
ep = ep.split('/')[0].split(':')[0].strip()
|
|
43
|
+
config['bucket_name'] = bn
|
|
44
|
+
config['access_key_id'] = ak
|
|
45
|
+
config['access_key_secret'] = sk
|
|
46
|
+
config['endpoint'] = ep
|
|
47
|
+
if not bn:
|
|
48
|
+
return {
|
|
49
|
+
'success': False,
|
|
50
|
+
'error': '未设置环境变量 OSS_BUCKET_NAME(或值为空)',
|
|
51
|
+
}
|
|
52
|
+
if not ak or not sk:
|
|
53
|
+
return {
|
|
54
|
+
'success': False,
|
|
55
|
+
'error': '未设置 OSS_ACCESS_KEY_ID 或 OSS_ACCESS_KEY_SECRET',
|
|
56
|
+
}
|
|
57
|
+
if not ep:
|
|
58
|
+
return {'success': False, 'error': 'OSS_ENDPOINT 为空'}
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def oss_put_url(bucket: str, endpoint: str, object_key: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
构造 PUT 用的 HTTPS URL。object_key 含中文时须对路径做百分号编码,
|
|
65
|
+
否则 http.client 会按 ascii 发请求,触发 UnicodeEncodeError。
|
|
66
|
+
签名里的 CanonicalizedResource 仍用原始 UTF-8 object_key(见各上传函数)。
|
|
67
|
+
"""
|
|
68
|
+
path = quote(object_key, safe='/')
|
|
69
|
+
return f"https://{bucket}.{endpoint}/{path}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_gmt_time():
|
|
73
|
+
"""获取GMT时间格式"""
|
|
74
|
+
return datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def calculate_md5(content):
|
|
78
|
+
"""计算Content-MD5"""
|
|
79
|
+
md5_hash = hashlib.md5(content).digest()
|
|
80
|
+
return base64.b64encode(md5_hash).decode('utf-8')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sign_request(method, content_md5, content_type, date, canonicalized_headers, canonicalized_resource, access_key_secret):
|
|
84
|
+
"""生成OSS签名"""
|
|
85
|
+
string_to_sign = f"{method}\n{content_md5}\n{content_type}\n{date}\n{canonicalized_headers}{canonicalized_resource}"
|
|
86
|
+
signature = base64.b64encode(
|
|
87
|
+
hmac.new(access_key_secret.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1).digest()
|
|
88
|
+
).decode('utf-8')
|
|
89
|
+
return signature
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def upload_markdown(local_file_path, object_key=None, custom_headers=None):
|
|
93
|
+
"""
|
|
94
|
+
上传Markdown文件到OSS
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
local_file_path: 本地markdown文件路径
|
|
98
|
+
object_key: OSS上的对象路径,默认为自动生成的路径
|
|
99
|
+
custom_headers: 自定义HTTP头
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
dict: 包含上传结果的字典
|
|
103
|
+
"""
|
|
104
|
+
config = get_oss_config()
|
|
105
|
+
bad = normalize_and_validate_oss_config(config)
|
|
106
|
+
if bad:
|
|
107
|
+
return bad
|
|
108
|
+
|
|
109
|
+
# 检查文件是否存在
|
|
110
|
+
if not os.path.exists(local_file_path):
|
|
111
|
+
return {
|
|
112
|
+
'success': False,
|
|
113
|
+
'error': f'文件不存在: {local_file_path}'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# 检查文件类型
|
|
117
|
+
if not local_file_path.lower().endswith('.md'):
|
|
118
|
+
return {
|
|
119
|
+
'success': False,
|
|
120
|
+
'error': '只支持上传.md格式的markdown文件'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# 读取文件内容
|
|
124
|
+
try:
|
|
125
|
+
with open(local_file_path, 'rb') as f:
|
|
126
|
+
content = f.read()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return {
|
|
129
|
+
'success': False,
|
|
130
|
+
'error': f'读取文件失败: {str(e)}'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# 如果没有指定object_key,自动生成
|
|
134
|
+
if not object_key:
|
|
135
|
+
filename = os.path.basename(local_file_path)
|
|
136
|
+
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
137
|
+
object_key = f"markdown/{timestamp}_{filename}"
|
|
138
|
+
|
|
139
|
+
# 确保路径以markdown/开头(如果用户没指定自定义路径)
|
|
140
|
+
if not object_key.startswith('/') and not object_key.startswith('http'):
|
|
141
|
+
if not object_key.startswith('markdown/') and not object_key.startswith('upload/'):
|
|
142
|
+
object_key = f"markdown/{object_key}"
|
|
143
|
+
|
|
144
|
+
# 计算Content-MD5
|
|
145
|
+
content_md5 = calculate_md5(content)
|
|
146
|
+
content_type = "text/markdown; charset=utf-8"
|
|
147
|
+
date = get_gmt_time()
|
|
148
|
+
canonicalized_resource = f"/{config['bucket_name']}/{object_key}"
|
|
149
|
+
|
|
150
|
+
# 生成签名
|
|
151
|
+
signature = sign_request("PUT", content_md5, content_type, date, "", canonicalized_resource, config['access_key_secret'])
|
|
152
|
+
|
|
153
|
+
# 构建请求
|
|
154
|
+
url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
155
|
+
headers = {
|
|
156
|
+
'Date': date,
|
|
157
|
+
'Content-Type': content_type,
|
|
158
|
+
'Content-MD5': content_md5,
|
|
159
|
+
'Authorization': f"OSS {config['access_key_id']}:{signature}"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# 添加自定义头
|
|
163
|
+
if custom_headers:
|
|
164
|
+
headers.update(custom_headers)
|
|
165
|
+
|
|
166
|
+
# 发送PUT请求
|
|
167
|
+
req = urllib.request.Request(url, data=content, headers=headers, method='PUT')
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
171
|
+
if response.status == 200:
|
|
172
|
+
oss_url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
173
|
+
return {
|
|
174
|
+
'success': True,
|
|
175
|
+
'url': oss_url,
|
|
176
|
+
'object_key': object_key,
|
|
177
|
+
'bucket': config['bucket_name'],
|
|
178
|
+
'endpoint': config['endpoint'],
|
|
179
|
+
'local_path': local_file_path,
|
|
180
|
+
'file_size': len(content),
|
|
181
|
+
'message': '上传成功'
|
|
182
|
+
}
|
|
183
|
+
else:
|
|
184
|
+
return {
|
|
185
|
+
'success': False,
|
|
186
|
+
'error': f'上传失败,HTTP状态码: {response.status}'
|
|
187
|
+
}
|
|
188
|
+
except urllib.error.HTTPError as e:
|
|
189
|
+
error_body = e.read().decode('utf-8') if hasattr(e, 'read') else str(e)
|
|
190
|
+
return {
|
|
191
|
+
'success': False,
|
|
192
|
+
'error': f'HTTP错误 {e.code}: {error_body}'
|
|
193
|
+
}
|
|
194
|
+
except Exception as e:
|
|
195
|
+
return {
|
|
196
|
+
'success': False,
|
|
197
|
+
'error': f'上传失败: {str(e)}'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def upload_markdown_content(content, filename, object_key=None):
|
|
202
|
+
"""
|
|
203
|
+
上传Markdown内容(字符串)到OSS
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
content: markdown内容字符串
|
|
207
|
+
filename: 文件名(用于生成默认object_key)
|
|
208
|
+
object_key: OSS上的对象路径
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
dict: 包含上传结果的字典
|
|
212
|
+
"""
|
|
213
|
+
config = get_oss_config()
|
|
214
|
+
bad = normalize_and_validate_oss_config(config)
|
|
215
|
+
if bad:
|
|
216
|
+
return bad
|
|
217
|
+
|
|
218
|
+
# 将字符串转换为bytes
|
|
219
|
+
if isinstance(content, str):
|
|
220
|
+
content = content.encode('utf-8')
|
|
221
|
+
|
|
222
|
+
# 如果没有指定object_key,自动生成
|
|
223
|
+
if not object_key:
|
|
224
|
+
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
225
|
+
safe_filename = filename.replace(' ', '_').replace('/', '_')
|
|
226
|
+
object_key = f"markdown/{timestamp}_{safe_filename}"
|
|
227
|
+
|
|
228
|
+
# 确保路径以markdown/开头
|
|
229
|
+
if not object_key.startswith('/') and not object_key.startswith('http'):
|
|
230
|
+
if not object_key.startswith('markdown/') and not object_key.startswith('upload/'):
|
|
231
|
+
object_key = f"markdown/{object_key}"
|
|
232
|
+
|
|
233
|
+
# 计算Content-MD5
|
|
234
|
+
content_md5 = calculate_md5(content)
|
|
235
|
+
content_type = "text/markdown; charset=utf-8"
|
|
236
|
+
date = get_gmt_time()
|
|
237
|
+
canonicalized_resource = f"/{config['bucket_name']}/{object_key}"
|
|
238
|
+
|
|
239
|
+
# 生成签名
|
|
240
|
+
signature = sign_request("PUT", content_md5, content_type, date, "", canonicalized_resource, config['access_key_secret'])
|
|
241
|
+
|
|
242
|
+
# 构建请求
|
|
243
|
+
url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
244
|
+
headers = {
|
|
245
|
+
'Date': date,
|
|
246
|
+
'Content-Type': content_type,
|
|
247
|
+
'Content-MD5': content_md5,
|
|
248
|
+
'Authorization': f"OSS {config['access_key_id']}:{signature}"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# 发送PUT请求
|
|
252
|
+
req = urllib.request.Request(url, data=content, headers=headers, method='PUT')
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
256
|
+
if response.status == 200:
|
|
257
|
+
oss_url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
258
|
+
return {
|
|
259
|
+
'success': True,
|
|
260
|
+
'url': oss_url,
|
|
261
|
+
'object_key': object_key,
|
|
262
|
+
'bucket': config['bucket_name'],
|
|
263
|
+
'endpoint': config['endpoint'],
|
|
264
|
+
'filename': filename,
|
|
265
|
+
'file_size': len(content),
|
|
266
|
+
'message': '上传成功'
|
|
267
|
+
}
|
|
268
|
+
else:
|
|
269
|
+
return {
|
|
270
|
+
'success': False,
|
|
271
|
+
'error': f'上传失败,HTTP状态码: {response.status}'
|
|
272
|
+
}
|
|
273
|
+
except urllib.error.HTTPError as e:
|
|
274
|
+
error_body = e.read().decode('utf-8') if hasattr(e, 'read') else str(e)
|
|
275
|
+
return {
|
|
276
|
+
'success': False,
|
|
277
|
+
'error': f'HTTP错误 {e.code}: {error_body}'
|
|
278
|
+
}
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return {
|
|
281
|
+
'success': False,
|
|
282
|
+
'error': f'上传失败: {str(e)}'
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def upload_docx_file(local_file_path, object_key=None, custom_headers=None):
|
|
287
|
+
"""
|
|
288
|
+
上传Word文档(.docx)到OSS
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
local_file_path: 本地docx文件路径
|
|
292
|
+
object_key: OSS上的对象路径,默认为自动生成的路径
|
|
293
|
+
custom_headers: 自定义HTTP头
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
dict: 包含上传结果的字典
|
|
297
|
+
"""
|
|
298
|
+
config = get_oss_config()
|
|
299
|
+
bad = normalize_and_validate_oss_config(config)
|
|
300
|
+
if bad:
|
|
301
|
+
return bad
|
|
302
|
+
|
|
303
|
+
# 检查文件是否存在
|
|
304
|
+
if not os.path.exists(local_file_path):
|
|
305
|
+
return {
|
|
306
|
+
'success': False,
|
|
307
|
+
'error': f'文件不存在: {local_file_path}'
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
# 检查文件类型
|
|
311
|
+
if not local_file_path.lower().endswith('.docx'):
|
|
312
|
+
return {
|
|
313
|
+
'success': False,
|
|
314
|
+
'error': '只支持上传.docx格式的Word文档'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# 读取文件内容
|
|
318
|
+
try:
|
|
319
|
+
with open(local_file_path, 'rb') as f:
|
|
320
|
+
content = f.read()
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return {
|
|
323
|
+
'success': False,
|
|
324
|
+
'error': f'读取文件失败: {str(e)}'
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# 如果没有指定object_key,自动生成
|
|
328
|
+
if not object_key:
|
|
329
|
+
filename = os.path.basename(local_file_path)
|
|
330
|
+
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
331
|
+
object_key = f"word/{timestamp}_{filename}"
|
|
332
|
+
|
|
333
|
+
# 确保路径以word/开头(如果用户没指定自定义路径)
|
|
334
|
+
if not object_key.startswith('/') and not object_key.startswith('http'):
|
|
335
|
+
if not object_key.startswith('word/') and not object_key.startswith('upload/'):
|
|
336
|
+
object_key = f"word/{object_key}"
|
|
337
|
+
|
|
338
|
+
# 计算Content-MD5
|
|
339
|
+
content_md5 = calculate_md5(content)
|
|
340
|
+
content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
341
|
+
date = get_gmt_time()
|
|
342
|
+
canonicalized_resource = f"/{config['bucket_name']}/{object_key}"
|
|
343
|
+
|
|
344
|
+
# 生成签名
|
|
345
|
+
signature = sign_request("PUT", content_md5, content_type, date, "", canonicalized_resource, config['access_key_secret'])
|
|
346
|
+
|
|
347
|
+
# 构建请求
|
|
348
|
+
url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
349
|
+
headers = {
|
|
350
|
+
'Date': date,
|
|
351
|
+
'Content-Type': content_type,
|
|
352
|
+
'Content-MD5': content_md5,
|
|
353
|
+
'Authorization': f"OSS {config['access_key_id']}:{signature}"
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# 添加自定义头
|
|
357
|
+
if custom_headers:
|
|
358
|
+
headers.update(custom_headers)
|
|
359
|
+
|
|
360
|
+
# 发送PUT请求
|
|
361
|
+
req = urllib.request.Request(url, data=content, headers=headers, method='PUT')
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
365
|
+
if response.status == 200:
|
|
366
|
+
oss_url = oss_put_url(config['bucket_name'], config['endpoint'], object_key)
|
|
367
|
+
return {
|
|
368
|
+
'success': True,
|
|
369
|
+
'url': oss_url,
|
|
370
|
+
'object_key': object_key,
|
|
371
|
+
'bucket': config['bucket_name'],
|
|
372
|
+
'endpoint': config['endpoint'],
|
|
373
|
+
'local_path': local_file_path,
|
|
374
|
+
'file_size': len(content),
|
|
375
|
+
'message': '上传成功'
|
|
376
|
+
}
|
|
377
|
+
else:
|
|
378
|
+
return {
|
|
379
|
+
'success': False,
|
|
380
|
+
'error': f'上传失败,HTTP状态码: {response.status}'
|
|
381
|
+
}
|
|
382
|
+
except urllib.error.HTTPError as e:
|
|
383
|
+
error_body = e.read().decode('utf-8') if hasattr(e, 'read') else str(e)
|
|
384
|
+
return {
|
|
385
|
+
'success': False,
|
|
386
|
+
'error': f'HTTP错误 {e.code}: {error_body}'
|
|
387
|
+
}
|
|
388
|
+
except Exception as e:
|
|
389
|
+
return {
|
|
390
|
+
'success': False,
|
|
391
|
+
'error': f'上传失败: {str(e)}'
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
import argparse
|
|
397
|
+
|
|
398
|
+
parser = argparse.ArgumentParser(description='上传文件到阿里云OSS')
|
|
399
|
+
parser.add_argument('file', help='本地文件路径')
|
|
400
|
+
parser.add_argument('--object-key', '-o', help='OSS对象路径(可选)')
|
|
401
|
+
parser.add_argument('--json', '-j', action='store_true', help='输出JSON格式结果')
|
|
402
|
+
parser.add_argument('--docx', action='store_true', help='上传Word文档(.docx)')
|
|
403
|
+
|
|
404
|
+
args = parser.parse_args()
|
|
405
|
+
|
|
406
|
+
if args.docx:
|
|
407
|
+
result = upload_docx_file(args.file, args.object_key)
|
|
408
|
+
else:
|
|
409
|
+
result = upload_markdown(args.file, args.object_key)
|
|
410
|
+
|
|
411
|
+
if args.json:
|
|
412
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
413
|
+
else:
|
|
414
|
+
if result['success']:
|
|
415
|
+
print(f"✅ {result['message']}")
|
|
416
|
+
print(f"📁 本地文件: {result['local_path']}")
|
|
417
|
+
print(f"🔗 OSS URL: {result['url']}")
|
|
418
|
+
print(f"📊 文件大小: {result['file_size']} 字节")
|
|
419
|
+
else:
|
|
420
|
+
print(f"❌ {result['error']}")
|
|
421
|
+
sys.exit(1)
|