Skip to content

Commit 3cf54a4

Browse files
author
姚嘉伦
committed
feat: enhance get-d2c tool to save D2C data locally
1 parent 3c5f57d commit 3cf54a4

File tree

1 file changed

+282
-41
lines changed

1 file changed

+282
-41
lines changed

src/tools/get-d2c.ts

Lines changed: 282 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,273 @@
11
import { z } from "zod";
22
import { BaseTool } from "./base-tool";
33
import { httpUtilInstance } from "../utils/api";
4+
import axios from "axios";
5+
import path from "path";
6+
import { existsSync, mkdirSync } from "fs";
7+
import { writeFile } from "fs/promises";
48

59
const D2C_TOOL_NAME = "mcp__getD2c";
610
const D2C_TOOL_DESCRIPTION = `
7-
使用此工具从 MasterGo 获取 D2C 数据。
8-
返回结果是一个 JSON 字符串,示例结构为:
9-
{
10-
"code": "00000",
11-
"message": "✅ 请求成功",
12-
"status": 200,
13-
"data": {
14-
"payload": {
15-
"code": "code string",
16-
"contentId": "contentId string",
17-
"frameType": "frameType string",
18-
"image": {
19-
"396102588418ac9fff2dead11ae585ce.png": "https://image-resource.mastergo.com/164019242047676/169610758561058/396102588418ac9fff2dead11ae585ce.png",
20-
"397816b5a878c61c85b8dba8c05d7347.png": "https://image-resource.mastergo.com/164019242047676/169610758561058/397816b5a878c61c85b8dba8c05d7347.png"
21-
},
22-
"svg": {
23-
"396102588418ac9fff2dead11ae585ce.svg": "<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><circle cx='50' cy='50' r='40' fill='red'/></svg>",
24-
},
25-
"resourcePath": {
26-
"image": "./asset/images/",
27-
"svg": "./asset/icons/",
28-
"shape": ""
29-
},
11+
使用此工具从 MasterGo 获取 D2C 数据,并在本地落盘:
12+
1)将返回的 code 写入 html;
13+
2)将返回的 svg / image 资源按 resourcePath 落盘到对应目录;
14+
3)返回落盘摘要,避免把大体积资源塞进上下文。
15+
`;
16+
17+
type ResourcePathMap = Record<"image" | "svg", string>;
18+
19+
type SaveResult = {
20+
targetDir: string;
21+
htmlFileName: string;
22+
htmlPath: string;
23+
svgCount: number;
24+
imageCount: number;
25+
resourcePathMap: ResourcePathMap;
26+
};
27+
28+
type WriteResourceResult = {
29+
savedCount: number;
30+
attemptedCount: number;
31+
errorCount: number;
32+
};
33+
34+
function isEmpty(value: any): boolean {
35+
if (value === null || value === undefined) return true;
36+
if (typeof value === "string") return value.trim().length === 0;
37+
if (Array.isArray(value)) return value.length === 0;
38+
if (typeof value === "object") return Object.keys(value).length === 0;
39+
return false;
40+
}
41+
42+
function hasContent(value: any): boolean {
43+
if (value === null || value === undefined) return false;
44+
if (typeof value === "string") return value.trim().length > 0;
45+
if (Array.isArray(value)) return value.length > 0;
46+
if (typeof value === "object") return Object.keys(value).length > 0;
47+
return false;
48+
}
49+
50+
function pickFirstWithContent(values: any[]): any {
51+
for (const v of values) {
52+
if (hasContent(v)) return v;
53+
}
54+
return undefined;
55+
}
56+
57+
function parseResourcePath(resourcePath: any): ResourcePathMap {
58+
const map: ResourcePathMap = {
59+
image: "asset/images",
60+
svg: "asset/icons",
61+
};
62+
if (!isEmpty(resourcePath)) {
63+
try {
64+
const parsed = typeof resourcePath === "string" ? JSON.parse(resourcePath) : resourcePath;
65+
if (parsed.image) {
66+
map.image = String(parsed.image)
67+
.replace(/^(\.\/|\/)/, "")
68+
.replace(/\/+$/, "");
69+
}
70+
if (parsed.svg) {
71+
map.svg = String(parsed.svg).replace(/^(\.\/|\/)/, "").replace(/\/+$/, "");
72+
}
73+
} catch {
74+
return map;
75+
}
76+
}
77+
return map;
78+
}
79+
80+
async function writeResource(
81+
resData: any,
82+
targetDir: string,
83+
folderName: string,
84+
ext: string
85+
): Promise<WriteResourceResult> {
86+
if (isEmpty(resData)) return { savedCount: 0, attemptedCount: 0, errorCount: 0 };
87+
88+
let parsed: any;
89+
try {
90+
parsed = typeof resData === "string" ? JSON.parse(resData) : resData;
91+
} catch {
92+
return { savedCount: 0, attemptedCount: 0, errorCount: 1 };
93+
}
94+
95+
if (!parsed || typeof parsed !== "object") {
96+
return { savedCount: 0, attemptedCount: 0, errorCount: 1 };
97+
}
98+
99+
const keys = Object.keys(parsed);
100+
if (keys.length === 0) {
101+
return { savedCount: 0, attemptedCount: 0, errorCount: 0 };
102+
}
103+
104+
const resDir = path.join(targetDir, folderName);
105+
if (!existsSync(resDir)) mkdirSync(resDir, { recursive: true });
106+
107+
let successCount = 0;
108+
let errorCount = 0;
109+
110+
await Promise.all(
111+
Object.entries(parsed).map(async ([key, value]) => {
112+
const match = String(key).match(/(.+)\.([a-zA-Z0-9]+)$/);
113+
const safeKey = (match ? match[1] : key).replace(/[^a-zA-Z0-9_-]/g, "_");
114+
const finalExt = match ? match[2] : ext;
115+
const filePath = path.join(resDir, `${safeKey}.${finalExt}`);
116+
117+
const content = value as any;
118+
119+
try {
120+
if (typeof content === "string" && content.startsWith("http")) {
121+
try {
122+
const response = await axios.get(content, {
123+
responseType: "arraybuffer",
124+
timeout: 30000,
125+
});
126+
await writeFile(filePath, response.data);
127+
successCount += 1;
128+
return;
129+
} catch (err: any) {
130+
const isWrongSsl =
131+
err?.code === "EPROTO" ||
132+
String(err?.message ?? "").includes("wrong version number");
133+
134+
// 有些资源链接会误把 http 服务包装成 https,导致 EPROTO:
135+
// 尝试回退到 http 再请求一次。
136+
if (isWrongSsl && content.startsWith("https://")) {
137+
const httpUrl = content.replace(/^https:\/\//, "http://");
138+
const response = await axios.get(httpUrl, {
139+
responseType: "arraybuffer",
140+
timeout: 30000,
141+
});
142+
await writeFile(filePath, response.data);
143+
successCount += 1;
144+
return;
145+
}
146+
147+
errorCount += 1;
148+
return;
149+
}
150+
}
151+
152+
if (typeof content === "string" && content.startsWith("data:image/")) {
153+
const parts = content.split(";base64,");
154+
if (parts.length === 2) {
155+
await writeFile(filePath, parts[1], "base64");
156+
successCount += 1;
157+
}
158+
return;
30159
}
160+
161+
const dataToWrite =
162+
typeof content === "object" ? JSON.stringify(content, null, 2) : String(content ?? "");
163+
const encoding: BufferEncoding =
164+
finalExt === "png" || finalExt === "jpg" || finalExt === "jpeg" ? "base64" : "utf8";
165+
await writeFile(filePath, dataToWrite, encoding);
166+
successCount += 1;
167+
} catch {
168+
errorCount += 1;
169+
return;
31170
}
171+
})
172+
);
173+
174+
return { savedCount: successCount, attemptedCount: keys.length, errorCount };
175+
}
176+
177+
async function saveCodeAndResources(params: {
178+
outDir?: string;
179+
contentId: string;
180+
code: string;
181+
resourcePath?: any;
182+
svg?: any;
183+
image?: any;
184+
}): Promise<SaveResult> {
185+
const { outDir, contentId, code, resourcePath, svg, image } = params;
186+
187+
const targetDir = outDir
188+
? path.isAbsolute(outDir)
189+
? path.join(outDir)
190+
: path.join(process.cwd(), outDir)
191+
: process.cwd();
192+
193+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
194+
195+
const htmlFileName = `${contentId || "index"}.html`;
196+
const htmlPath = path.join(targetDir, htmlFileName);
197+
198+
if (!isEmpty(code)) {
199+
await writeFile(htmlPath, code, "utf8");
32200
}
201+
202+
const resPathMap = parseResourcePath(resourcePath);
203+
204+
// 即使资源为空,也确保目录按 resourcePath 规划创建出来,便于后续排查
205+
const ensureResDir = (folderName: string) => {
206+
const dirPath = path.join(targetDir, folderName);
207+
if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true });
208+
};
209+
ensureResDir(resPathMap.image);
210+
ensureResDir(resPathMap.svg);
211+
212+
213+
const [svgWrite, imageWrite] = await Promise.all([
214+
writeResource(svg, targetDir, resPathMap.svg, "svg"),
215+
writeResource(image, targetDir, resPathMap.image, "png"),
216+
]);
217+
218+
return {
219+
targetDir,
220+
htmlFileName,
221+
htmlPath,
222+
svgCount: svgWrite.savedCount,
223+
imageCount: imageWrite.savedCount,
224+
resourcePathMap: resPathMap,
225+
};
226+
}
227+
228+
function extractPayload(d2c: any): {
229+
contentId: string;
230+
frameType?: string;
231+
code: string;
232+
resourcePath?: any;
233+
shape?: any;
234+
svg?: any;
235+
image?: any;
236+
} {
237+
const data = d2c?.data;
238+
const firstItem = Array.isArray(data) ? data[0] : undefined;
239+
240+
const payload =
241+
firstItem?.payload ??
242+
d2c?.payload ??
243+
d2c?.data?.payload ??
244+
firstItem?.payload?.payload ??
245+
data?.payload ??
246+
{};
247+
248+
const codeCandidate =
249+
payload?.code ?? payload?.html ?? payload?.content ?? d2c?.code ?? "";
250+
251+
const resourcePath = pickFirstWithContent([
252+
payload?.resourcePath,
253+
d2c?.resourcePath,
254+
firstItem?.resourcePath,
255+
]);
256+
257+
const image = pickFirstWithContent([payload?.image, firstItem?.image]);
258+
const svg = pickFirstWithContent([payload?.svg, firstItem?.svg]);
259+
const shape = pickFirstWithContent([payload?.shape, firstItem?.shape]);
260+
261+
return {
262+
contentId: String(firstItem?.contentId ?? payload?.contentId ?? d2c?.contentId ?? ""),
263+
frameType: payload?.frameType ?? firstItem?.frameType ?? d2c?.frameType,
264+
code: String(codeCandidate ?? ""),
265+
resourcePath,
266+
shape,
267+
svg,
268+
image,
269+
};
33270
}
34-
使用此工具后,请按以下 todo 执行:
35-
1)为每个资源(返回的svg和image)在本地按 resourcePath 落盘:
36-
- 确保 resourcePath 对应目录存在;
37-
- 将返回的真实svg按照resourcePath的svg目录落盘;
38-
- 将返回的真实image按照resourcePath的image目录落盘;
39-
2)使用 code 字段进行代码生成;
40-
`;
41271

42272
export class GetD2cTool extends BaseTool {
43273
name = D2C_TOOL_NAME;
@@ -58,24 +288,34 @@ export class GetD2cTool extends BaseTool {
58288
.describe(
59289
"MasterGo 文档 ID,通常为 contentId 的第一段,例如 contentId 为 176452330285910-2-9032 时 documentId 为 176452330285910。"
60290
),
291+
outDir: z
292+
.string()
293+
.optional()
294+
.describe("可选,输出目录(绝对路径或相对当前工作目录)。"),
61295
});
62296

63297
async execute({
64298
contentId,
65299
documentId,
300+
outDir,
66301
}: z.infer<typeof this.schema>) {
67302
try {
68-
if (!contentId) {
69-
throw new Error("contentId 不能为空");
70-
}
71-
if (!documentId) {
72-
throw new Error("documentId 不能为空");
73-
}
303+
if (!contentId) throw new Error("contentId 不能为空");
304+
if (!documentId) throw new Error("documentId 不能为空");
74305

75-
const d2c = await httpUtilInstance.getD2c(
76-
contentId,
77-
documentId
78-
);
306+
const d2c = await httpUtilInstance.getD2c(contentId, documentId);
307+
308+
const payloadExtracted = extractPayload(d2c);
309+
const finalContentId = payloadExtracted.contentId || contentId;
310+
311+
await saveCodeAndResources({
312+
outDir,
313+
contentId: finalContentId,
314+
code: payloadExtracted.code,
315+
resourcePath: payloadExtracted.resourcePath,
316+
svg: payloadExtracted.svg,
317+
image: payloadExtracted.image,
318+
});
79319

80320
return {
81321
content: [
@@ -95,6 +335,7 @@ export class GetD2cTool extends BaseTool {
95335
text: JSON.stringify(errorMessage),
96336
},
97337
],
98-
}; }
338+
};
339+
}
99340
}
100341
}

0 commit comments

Comments
 (0)