design-learn-server 0.1.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/README.md +123 -0
- package/package.json +29 -0
- package/src/cli.js +152 -0
- package/src/mcp/index.js +556 -0
- package/src/pipeline/index.js +335 -0
- package/src/playwrightSupport.js +65 -0
- package/src/preview/index.js +204 -0
- package/src/server.js +1385 -0
- package/src/stdio.js +464 -0
- package/src/storage/fileStore.js +45 -0
- package/src/storage/index.js +983 -0
- package/src/storage/paths.js +113 -0
- package/src/storage/sqliteStore.js +114 -0
- package/src/uipro/bm25.js +121 -0
- package/src/uipro/config.js +264 -0
- package/src/uipro/csv.js +90 -0
- package/src/uipro/data/charts.csv +26 -0
- package/src/uipro/data/colors.csv +97 -0
- package/src/uipro/data/icons.csv +101 -0
- package/src/uipro/data/landing.csv +31 -0
- package/src/uipro/data/products.csv +97 -0
- package/src/uipro/data/prompts.csv +24 -0
- package/src/uipro/data/stacks/flutter.csv +53 -0
- package/src/uipro/data/stacks/html-tailwind.csv +56 -0
- package/src/uipro/data/stacks/nextjs.csv +53 -0
- package/src/uipro/data/stacks/nuxt-ui.csv +51 -0
- package/src/uipro/data/stacks/nuxtjs.csv +59 -0
- package/src/uipro/data/stacks/react-native.csv +52 -0
- package/src/uipro/data/stacks/react.csv +54 -0
- package/src/uipro/data/stacks/shadcn.csv +61 -0
- package/src/uipro/data/stacks/svelte.csv +54 -0
- package/src/uipro/data/stacks/swiftui.csv +51 -0
- package/src/uipro/data/stacks/vue.csv +50 -0
- package/src/uipro/data/styles.csv +59 -0
- package/src/uipro/data/typography.csv +58 -0
- package/src/uipro/data/ux-guidelines.csv +100 -0
- package/src/uipro/index.js +581 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PORT = Number(process.env.PORT || process.env.DESIGN_LEARN_PORT || 3100);
|
|
9
|
+
const WS_CLOSE_DELAY_MS = 500;
|
|
10
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
11
|
+
const { createMcpHandler } = require('./mcp');
|
|
12
|
+
const { createStorage } = require('./storage');
|
|
13
|
+
const { createExtractionPipeline } = require('./pipeline');
|
|
14
|
+
const { createPreviewPipeline } = require('./preview');
|
|
15
|
+
const { createUipro } = require('./uipro');
|
|
16
|
+
const { loadPlaywright } = require('./playwrightSupport');
|
|
17
|
+
const { getConfigPath } = require('./storage/paths');
|
|
18
|
+
const { readJson, writeJson } = require('./storage/fileStore');
|
|
19
|
+
|
|
20
|
+
// 数据目录优先级:环境变量 > 用户目录下的 ~/.design-learn/data
|
|
21
|
+
// 与 stdio.js 保持一致,确保 MCP/HTTP/Chrome/VSCode 使用同一数据源
|
|
22
|
+
const defaultDataDir = path.join(os.homedir(), '.design-learn', 'data');
|
|
23
|
+
const dataDir = process.env.DESIGN_LEARN_DATA_DIR || process.env.DATA_DIR || defaultDataDir;
|
|
24
|
+
|
|
25
|
+
const storage = createStorage({ dataDir });
|
|
26
|
+
const uipro = createUipro({ dataDir });
|
|
27
|
+
const extractionPipeline = createExtractionPipeline({ storage });
|
|
28
|
+
const previewPipeline = createPreviewPipeline({ storage });
|
|
29
|
+
|
|
30
|
+
// import job -> designId,用于将 pipeline 状态回写到 design metadata(便于 UI 与 MCP 立刻可见)
|
|
31
|
+
const importJobDesignMap = new Map();
|
|
32
|
+
|
|
33
|
+
extractionPipeline.onProgress((event) => {
|
|
34
|
+
const designId = importJobDesignMap.get(event.job.id);
|
|
35
|
+
if (!designId) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const now = new Date().toISOString();
|
|
40
|
+
void (async () => {
|
|
41
|
+
const design = await storage.getDesign(designId);
|
|
42
|
+
const meta = design?.metadata || {};
|
|
43
|
+
const aiRequested = !!meta.aiRequested;
|
|
44
|
+
const aiCompleted = meta.processingMessage === 'ai_completed' || meta.aiCompleted;
|
|
45
|
+
|
|
46
|
+
const metaPatch = {
|
|
47
|
+
processingStatus: event.event === 'failed' ? 'failed' : event.event === 'completed' ? 'completed' : 'processing',
|
|
48
|
+
processingJobId: event.job.id,
|
|
49
|
+
processingUpdatedAt: now,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (event.event === 'failed') {
|
|
53
|
+
metaPatch.processingError = event.job.error?.message || 'unknown_error';
|
|
54
|
+
metaPatch.processingMessage = 'failed';
|
|
55
|
+
metaPatch.processingProgress = 100;
|
|
56
|
+
} else if (event.event === 'completed') {
|
|
57
|
+
metaPatch.processingError = null;
|
|
58
|
+
metaPatch.lastImportAt = now;
|
|
59
|
+
metaPatch.lastImportVersionId = event.job.result?.versionId || null;
|
|
60
|
+
if (aiRequested && !aiCompleted) {
|
|
61
|
+
metaPatch.processingStatus = 'analyzing';
|
|
62
|
+
metaPatch.processingMessage = 'ai_pending';
|
|
63
|
+
metaPatch.processingProgress = 80;
|
|
64
|
+
} else {
|
|
65
|
+
metaPatch.processingMessage = 'completed';
|
|
66
|
+
metaPatch.processingProgress = 100;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
metaPatch.processingMessage = event.job.message || 'processing';
|
|
70
|
+
metaPatch.processingProgress = event.job.progress || 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await storage.updateDesign(designId, { metadata: metaPatch });
|
|
74
|
+
})().catch(() => undefined);
|
|
75
|
+
|
|
76
|
+
if (event.event === 'failed' || event.event === 'completed') {
|
|
77
|
+
importJobDesignMap.delete(event.job.id);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const mcpHandler = createMcpHandler({
|
|
81
|
+
storage,
|
|
82
|
+
dataDir: process.env.DESIGN_LEARN_DATA_DIR,
|
|
83
|
+
serverName: process.env.MCP_SERVER_NAME,
|
|
84
|
+
serverVersion: process.env.MCP_SERVER_VERSION,
|
|
85
|
+
authToken: process.env.MCP_AUTH_TOKEN,
|
|
86
|
+
uipro,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const routes = [
|
|
90
|
+
{
|
|
91
|
+
method: 'GET',
|
|
92
|
+
path: '/',
|
|
93
|
+
handler: handleRoot,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
method: 'GET',
|
|
97
|
+
path: '/api/health',
|
|
98
|
+
handler: handleHealth,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function sendJson(res, status, body) {
|
|
103
|
+
const payload = JSON.stringify(body);
|
|
104
|
+
res.writeHead(status, {
|
|
105
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
106
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
107
|
+
});
|
|
108
|
+
res.end(payload);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendNoContent(res) {
|
|
112
|
+
res.writeHead(204);
|
|
113
|
+
res.end();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleRoot(req, res) {
|
|
117
|
+
sendJson(res, 200, {
|
|
118
|
+
name: 'design-learn-server',
|
|
119
|
+
status: 'ready',
|
|
120
|
+
endpoints: {
|
|
121
|
+
health: '/api/health',
|
|
122
|
+
importBrowser: '/api/import/browser',
|
|
123
|
+
importUrl: '/api/import/url',
|
|
124
|
+
importJobs: '/api/import/jobs',
|
|
125
|
+
importStream: '/api/import/stream',
|
|
126
|
+
designs: '/api/designs',
|
|
127
|
+
versions: '/api/versions/:id',
|
|
128
|
+
snapshots: '/api/snapshots',
|
|
129
|
+
config: '/api/config',
|
|
130
|
+
previews: '/api/previews',
|
|
131
|
+
tasks: '/api/tasks',
|
|
132
|
+
uiproDomains: '/api/uipro/domains',
|
|
133
|
+
uiproStacks: '/api/uipro/stacks',
|
|
134
|
+
uiproSearch: '/api/uipro/search',
|
|
135
|
+
uiproSearchStack: '/api/uipro/search-stack',
|
|
136
|
+
uiproBrowse: '/api/uipro/browse',
|
|
137
|
+
uiproSuggest: '/api/uipro/suggest',
|
|
138
|
+
uiproBrowseStack: '/api/uipro/browse-stack',
|
|
139
|
+
uiproSuggestStack: '/api/uipro/suggest-stack',
|
|
140
|
+
mcp: '/mcp',
|
|
141
|
+
ws: '/ws',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleHealth(req, res) {
|
|
147
|
+
sendJson(res, 200, {
|
|
148
|
+
status: 'healthy',
|
|
149
|
+
uptime: process.uptime(),
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function handleUiproDomains(res) {
|
|
155
|
+
sendJson(res, 200, {
|
|
156
|
+
source: 'ui-ux-pro-max',
|
|
157
|
+
domains: uipro.domains,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleUiproStacks(res) {
|
|
162
|
+
sendJson(res, 200, {
|
|
163
|
+
source: 'ui-ux-pro-max',
|
|
164
|
+
stacks: uipro.stacks,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleUiproSearch(res, url) {
|
|
169
|
+
const query = url.searchParams.get('query') || url.searchParams.get('q') || '';
|
|
170
|
+
const domain = url.searchParams.get('domain') || undefined;
|
|
171
|
+
const limitRaw = url.searchParams.get('limit');
|
|
172
|
+
const limitParsed = limitRaw ? Number(limitRaw) : undefined;
|
|
173
|
+
const limit = Number.isFinite(limitParsed) ? limitParsed : 10;
|
|
174
|
+
|
|
175
|
+
let data;
|
|
176
|
+
try {
|
|
177
|
+
data = uipro.search({ query, domain, limit });
|
|
178
|
+
} catch {
|
|
179
|
+
data = {
|
|
180
|
+
error: 'uipro_data_unavailable',
|
|
181
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
sendJson(res, 200, data);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleUiproSearchStack(res, url) {
|
|
189
|
+
const query = url.searchParams.get('query') || url.searchParams.get('q') || '';
|
|
190
|
+
const stack = url.searchParams.get('stack') || undefined;
|
|
191
|
+
const limitRaw = url.searchParams.get('limit');
|
|
192
|
+
const limitParsed = limitRaw ? Number(limitRaw) : undefined;
|
|
193
|
+
const limit = Number.isFinite(limitParsed) ? limitParsed : 10;
|
|
194
|
+
|
|
195
|
+
let data;
|
|
196
|
+
try {
|
|
197
|
+
data = uipro.searchStack({ query, stack, limit });
|
|
198
|
+
} catch {
|
|
199
|
+
data = {
|
|
200
|
+
error: 'uipro_data_unavailable',
|
|
201
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
sendJson(res, 200, data);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handleUiproBrowse(res, url) {
|
|
209
|
+
const domain = url.searchParams.get('domain') || undefined;
|
|
210
|
+
const { limit, offset } = parseLimitOffset(url);
|
|
211
|
+
|
|
212
|
+
let data;
|
|
213
|
+
try {
|
|
214
|
+
data = uipro.browse({ domain, limit, offset });
|
|
215
|
+
} catch {
|
|
216
|
+
data = {
|
|
217
|
+
error: 'uipro_data_unavailable',
|
|
218
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
sendJson(res, 200, data);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function handleUiproSuggest(res, url) {
|
|
226
|
+
const domain = url.searchParams.get('domain') || undefined;
|
|
227
|
+
const limitRaw = Number(url.searchParams.get('limit'));
|
|
228
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 50) : 20;
|
|
229
|
+
|
|
230
|
+
let data;
|
|
231
|
+
try {
|
|
232
|
+
data = uipro.suggest({ domain, limit });
|
|
233
|
+
} catch {
|
|
234
|
+
data = {
|
|
235
|
+
error: 'uipro_data_unavailable',
|
|
236
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
sendJson(res, 200, data);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handleUiproBrowseStack(res, url) {
|
|
244
|
+
const stack = url.searchParams.get('stack') || undefined;
|
|
245
|
+
const { limit, offset } = parseLimitOffset(url);
|
|
246
|
+
|
|
247
|
+
let data;
|
|
248
|
+
try {
|
|
249
|
+
data = uipro.browseStack({ stack, limit, offset });
|
|
250
|
+
} catch {
|
|
251
|
+
data = {
|
|
252
|
+
error: 'uipro_data_unavailable',
|
|
253
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
sendJson(res, 200, data);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleUiproSuggestStack(res, url) {
|
|
261
|
+
const stack = url.searchParams.get('stack') || undefined;
|
|
262
|
+
const limitRaw = Number(url.searchParams.get('limit'));
|
|
263
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 50) : 20;
|
|
264
|
+
|
|
265
|
+
let data;
|
|
266
|
+
try {
|
|
267
|
+
data = uipro.suggestStack({ stack, limit });
|
|
268
|
+
} catch {
|
|
269
|
+
data = {
|
|
270
|
+
error: 'uipro_data_unavailable',
|
|
271
|
+
hint: 'Check DESIGN_LEARN_UIPRO_DATA_DIR or built-in dataset integrity.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
sendJson(res, 200, data);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const DEFAULT_CONFIG = {
|
|
279
|
+
model: {
|
|
280
|
+
name: '',
|
|
281
|
+
version: '',
|
|
282
|
+
provider: '',
|
|
283
|
+
},
|
|
284
|
+
aiModels: [],
|
|
285
|
+
selectedModelId: '',
|
|
286
|
+
templates: {
|
|
287
|
+
styleguide: '',
|
|
288
|
+
components: '',
|
|
289
|
+
},
|
|
290
|
+
extractOptions: {
|
|
291
|
+
includeRules: true,
|
|
292
|
+
includeComponents: true,
|
|
293
|
+
},
|
|
294
|
+
updatedAt: null,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function parseLimitOffset(url) {
|
|
298
|
+
const limitRaw = Number(url.searchParams.get('limit'));
|
|
299
|
+
const offsetRaw = Number(url.searchParams.get('offset'));
|
|
300
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 100) : 20;
|
|
301
|
+
const offset = Number.isFinite(offsetRaw) ? Math.max(offsetRaw, 0) : 0;
|
|
302
|
+
return { limit, offset };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function paginate(items, limit, offset) {
|
|
306
|
+
const total = items.length;
|
|
307
|
+
const paged = items.slice(offset, offset + limit);
|
|
308
|
+
return { items: paged, total };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function loadConfig() {
|
|
312
|
+
const configPath = getConfigPath(storage.dataDir);
|
|
313
|
+
try {
|
|
314
|
+
return await readJson(configPath);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error.code === 'ENOENT') {
|
|
317
|
+
return { ...DEFAULT_CONFIG };
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeConfig(input) {
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
const model = input?.model || {};
|
|
326
|
+
const templates = input?.templates || {};
|
|
327
|
+
const extractOptions = input?.extractOptions || {};
|
|
328
|
+
const aiModels = Array.isArray(input?.aiModels) ? input.aiModels : [];
|
|
329
|
+
const selectedModelId = typeof input?.selectedModelId === 'string' ? input.selectedModelId : '';
|
|
330
|
+
return {
|
|
331
|
+
model: {
|
|
332
|
+
...DEFAULT_CONFIG.model,
|
|
333
|
+
...model,
|
|
334
|
+
},
|
|
335
|
+
aiModels,
|
|
336
|
+
selectedModelId,
|
|
337
|
+
templates: {
|
|
338
|
+
...DEFAULT_CONFIG.templates,
|
|
339
|
+
...templates,
|
|
340
|
+
},
|
|
341
|
+
extractOptions: {
|
|
342
|
+
...DEFAULT_CONFIG.extractOptions,
|
|
343
|
+
...extractOptions,
|
|
344
|
+
},
|
|
345
|
+
updatedAt: now,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function handleConfigGet(res) {
|
|
350
|
+
const config = await loadConfig();
|
|
351
|
+
sendJson(res, 200, config);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function handleConfigPut(req, res) {
|
|
355
|
+
const body = await readJsonBody(req, res);
|
|
356
|
+
if (!body) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (typeof body !== 'object' || Array.isArray(body)) {
|
|
360
|
+
return sendJson(res, 400, { error: 'invalid_config' });
|
|
361
|
+
}
|
|
362
|
+
const config = normalizeConfig(body);
|
|
363
|
+
await writeJson(getConfigPath(storage.dataDir), config);
|
|
364
|
+
return sendJson(res, 200, config);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isMcpPath(pathname) {
|
|
368
|
+
return pathname === '/mcp' || pathname.startsWith('/mcp/');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isWsPath(pathname) {
|
|
372
|
+
return pathname === '/ws' || pathname.startsWith('/ws/');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function handleWsHttpFallback(req, res) {
|
|
376
|
+
sendJson(res, 426, { error: 'upgrade_required' });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function findRoute(method, pathname) {
|
|
380
|
+
return routes.find((route) => route.method === method && route.path === pathname);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sendMethodNotAllowed(res) {
|
|
384
|
+
sendJson(res, 405, { error: 'method_not_allowed' });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function handleImportStream(req, res) {
|
|
388
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
389
|
+
const jobIdFilter = url.searchParams.get('jobId');
|
|
390
|
+
|
|
391
|
+
res.writeHead(200, {
|
|
392
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
393
|
+
'Cache-Control': 'no-cache',
|
|
394
|
+
Connection: 'keep-alive',
|
|
395
|
+
});
|
|
396
|
+
res.write(`event: connected\\ndata: ${JSON.stringify({ status: 'ok' })}\\n\\n`);
|
|
397
|
+
|
|
398
|
+
const unsubscribe = extractionPipeline.onProgress((event) => {
|
|
399
|
+
if (jobIdFilter && event.job.id !== jobIdFilter) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
res.write(`event: ${event.event}\\ndata: ${JSON.stringify(event)}\\n\\n`);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
req.on('close', () => {
|
|
406
|
+
unsubscribe();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function handleImportJobs(res) {
|
|
411
|
+
sendJson(res, 200, { jobs: extractionPipeline.listJobs() });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function handleImportJob(res, jobId) {
|
|
415
|
+
const job = extractionPipeline.getJob(jobId);
|
|
416
|
+
if (!job) {
|
|
417
|
+
return sendJson(res, 404, { error: 'job_not_found' });
|
|
418
|
+
}
|
|
419
|
+
return sendJson(res, 200, { job });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleImportBrowser(req, res) {
|
|
423
|
+
const body = await readJsonBody(req, res);
|
|
424
|
+
if (!body) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const job = extractionPipeline.enqueueImportFromBrowser(body);
|
|
430
|
+
sendJson(res, 202, { job });
|
|
431
|
+
} catch (error) {
|
|
432
|
+
sendJson(res, 400, { error: error.message });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function handleImportUrl(req, res) {
|
|
437
|
+
const body = await readJsonBody(req, res);
|
|
438
|
+
if (!body) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const rawUrl = body.url || '';
|
|
444
|
+
if (!rawUrl) {
|
|
445
|
+
return sendJson(res, 400, { error: 'url_required' });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
new URL(rawUrl);
|
|
450
|
+
} catch {
|
|
451
|
+
return sendJson(res, 400, { error: 'invalid_url' });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const requestedDesignId = body.designId || null;
|
|
455
|
+
let requestedDesign = null;
|
|
456
|
+
if (requestedDesignId) {
|
|
457
|
+
requestedDesign = await storage.getDesign(requestedDesignId);
|
|
458
|
+
if (!requestedDesign) {
|
|
459
|
+
return sendJson(res, 404, { error: 'design_not_found' });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const existing = requestedDesign ? null : storage.listDesigns().find((item) => item.url === rawUrl);
|
|
464
|
+
const now = new Date().toISOString();
|
|
465
|
+
const useAI = !!body?.options?.useAI;
|
|
466
|
+
|
|
467
|
+
let designId = requestedDesign?.id || existing?.id || null;
|
|
468
|
+
if (!designId) {
|
|
469
|
+
let name = rawUrl;
|
|
470
|
+
try {
|
|
471
|
+
const parsed = new URL(rawUrl);
|
|
472
|
+
name = parsed.hostname || rawUrl;
|
|
473
|
+
} catch {}
|
|
474
|
+
|
|
475
|
+
const design = await storage.createDesign({
|
|
476
|
+
name,
|
|
477
|
+
url: rawUrl,
|
|
478
|
+
source: 'script',
|
|
479
|
+
metadata: {
|
|
480
|
+
extractedFrom: 'playwright',
|
|
481
|
+
processingStatus: 'processing',
|
|
482
|
+
processingStartedAt: now,
|
|
483
|
+
processingError: null,
|
|
484
|
+
aiRequested: useAI,
|
|
485
|
+
aiCompleted: false,
|
|
486
|
+
tags: [],
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
designId = design.id;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const job = extractionPipeline.enqueueImportFromUrl({ ...body, designId });
|
|
493
|
+
importJobDesignMap.set(job.id, designId);
|
|
494
|
+
|
|
495
|
+
// 为已有 design 也补齐处理状态(避免 UI / MCP 端看到旧状态)
|
|
496
|
+
await storage.updateDesign(designId, {
|
|
497
|
+
metadata: {
|
|
498
|
+
processingStatus: 'processing',
|
|
499
|
+
processingStartedAt: now,
|
|
500
|
+
processingJobId: job.id,
|
|
501
|
+
processingError: null,
|
|
502
|
+
aiRequested: useAI,
|
|
503
|
+
...(useAI ? { aiCompleted: false } : {}),
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
sendJson(res, 202, { job, designId });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
sendJson(res, 400, { error: error.message });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function handleDesignList(res, url) {
|
|
514
|
+
const { limit, offset } = parseLimitOffset(url);
|
|
515
|
+
const designs = storage.listDesigns();
|
|
516
|
+
const { items, total } = paginate(designs, limit, offset);
|
|
517
|
+
sendJson(res, 200, { items, limit, offset, total });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function handleDesignCreate(req, res) {
|
|
521
|
+
const body = await readJsonBody(req, res);
|
|
522
|
+
if (!body) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const design = await storage.createDesign(body);
|
|
526
|
+
sendJson(res, 201, design);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function handleDesignGet(res, designId) {
|
|
530
|
+
const design = await storage.getDesign(designId);
|
|
531
|
+
if (!design) {
|
|
532
|
+
return sendJson(res, 404, { error: 'design_not_found' });
|
|
533
|
+
}
|
|
534
|
+
return sendJson(res, 200, design);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function handleDesignPatch(req, res, designId) {
|
|
538
|
+
const body = await readJsonBody(req, res);
|
|
539
|
+
if (!body) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const design = await storage.updateDesign(designId, body);
|
|
543
|
+
if (!design) {
|
|
544
|
+
return sendJson(res, 404, { error: 'design_not_found' });
|
|
545
|
+
}
|
|
546
|
+
return sendJson(res, 200, design);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function handleDesignDelete(res, designId) {
|
|
550
|
+
const design = await storage.getDesign(designId);
|
|
551
|
+
if (!design) {
|
|
552
|
+
return sendJson(res, 404, { error: 'design_not_found' });
|
|
553
|
+
}
|
|
554
|
+
await storage.deleteDesign(designId);
|
|
555
|
+
return sendNoContent(res);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function handleVersionGet(res, versionId) {
|
|
559
|
+
const version = await storage.getVersion(versionId);
|
|
560
|
+
if (!version) {
|
|
561
|
+
return sendJson(res, 404, { error: 'version_not_found' });
|
|
562
|
+
}
|
|
563
|
+
return sendJson(res, 200, version);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function handleVersionPatch(req, res, versionId) {
|
|
567
|
+
const body = await readJsonBody(req, res);
|
|
568
|
+
if (!body) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const patch = {};
|
|
573
|
+
if (Object.prototype.hasOwnProperty.call(body, 'styleguideMarkdown')) {
|
|
574
|
+
patch.styleguideMarkdown = body.styleguideMarkdown;
|
|
575
|
+
}
|
|
576
|
+
if (Object.prototype.hasOwnProperty.call(body, 'rules')) {
|
|
577
|
+
patch.rules = body.rules;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const version = await storage.updateVersion(versionId, patch);
|
|
581
|
+
if (!version) {
|
|
582
|
+
return sendJson(res, 404, { error: 'version_not_found' });
|
|
583
|
+
}
|
|
584
|
+
return sendJson(res, 200, version);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function handleSnapshotsList(res, url) {
|
|
588
|
+
const { limit, offset } = parseLimitOffset(url);
|
|
589
|
+
const filters = {};
|
|
590
|
+
const designId = url.searchParams.get('designId');
|
|
591
|
+
const versionId = url.searchParams.get('versionId');
|
|
592
|
+
if (designId) {
|
|
593
|
+
filters.designId = designId;
|
|
594
|
+
}
|
|
595
|
+
if (versionId) {
|
|
596
|
+
filters.versionId = versionId;
|
|
597
|
+
}
|
|
598
|
+
const snapshots = await storage.listSnapshots(filters);
|
|
599
|
+
const { items, total } = paginate(snapshots, limit, offset);
|
|
600
|
+
return sendJson(res, 200, { items, limit, offset, total });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function handleSnapshotGet(res, snapshotId) {
|
|
604
|
+
const snapshot = await storage.getSnapshot(snapshotId);
|
|
605
|
+
if (!snapshot) {
|
|
606
|
+
return sendJson(res, 404, { error: 'snapshot_not_found' });
|
|
607
|
+
}
|
|
608
|
+
return sendJson(res, 200, snapshot);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function handleSnapshotDelete(res, snapshotId) {
|
|
612
|
+
const snapshot = await storage.deleteSnapshot(snapshotId);
|
|
613
|
+
if (!snapshot) {
|
|
614
|
+
return sendJson(res, 404, { error: 'snapshot_not_found' });
|
|
615
|
+
}
|
|
616
|
+
return sendNoContent(res);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function handlePreviewJobs(res) {
|
|
620
|
+
sendJson(res, 200, { jobs: previewPipeline.listJobs() });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function handlePreviewJob(res, jobId) {
|
|
624
|
+
const job = previewPipeline.getJob(jobId);
|
|
625
|
+
if (!job) {
|
|
626
|
+
return sendJson(res, 404, { error: 'preview_job_not_found' });
|
|
627
|
+
}
|
|
628
|
+
return sendJson(res, 200, { job });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function handlePreviewEnqueue(req, res) {
|
|
632
|
+
const body = await readJsonBody(req, res);
|
|
633
|
+
if (!body) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const job = previewPipeline.enqueuePreview(body);
|
|
638
|
+
return sendJson(res, 202, { job });
|
|
639
|
+
} catch (error) {
|
|
640
|
+
return sendJson(res, 400, { error: error.message });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function handlePreviewGet(res, componentId) {
|
|
645
|
+
const component = await storage.getComponent(componentId);
|
|
646
|
+
if (!component) {
|
|
647
|
+
return sendJson(res, 404, { error: 'component_not_found' });
|
|
648
|
+
}
|
|
649
|
+
return sendJson(res, 200, {
|
|
650
|
+
componentId: component.id,
|
|
651
|
+
preview: component.preview || null,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ==================== 任务管理 API ====================
|
|
656
|
+
|
|
657
|
+
async function handleTasksList(res, url) {
|
|
658
|
+
const filters = {};
|
|
659
|
+
const status = url.searchParams.get('status');
|
|
660
|
+
const domain = url.searchParams.get('domain');
|
|
661
|
+
const excludeCompleted = url.searchParams.get('excludeCompleted') === 'true';
|
|
662
|
+
|
|
663
|
+
if (status) {
|
|
664
|
+
filters.status = status.split(',');
|
|
665
|
+
}
|
|
666
|
+
if (domain) {
|
|
667
|
+
filters.domain = domain;
|
|
668
|
+
}
|
|
669
|
+
if (excludeCompleted) {
|
|
670
|
+
filters.excludeCompleted = true;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const tasks = storage.listTasks(filters);
|
|
674
|
+
|
|
675
|
+
// 按域名分组
|
|
676
|
+
const groups = {};
|
|
677
|
+
tasks.forEach(task => {
|
|
678
|
+
if (!groups[task.domain]) {
|
|
679
|
+
groups[task.domain] = [];
|
|
680
|
+
}
|
|
681
|
+
groups[task.domain].push(task);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// 统计信息
|
|
685
|
+
const stats = {
|
|
686
|
+
total: tasks.length,
|
|
687
|
+
pending: tasks.filter(t => t.status === 'pending').length,
|
|
688
|
+
running: tasks.filter(t => t.status === 'running').length,
|
|
689
|
+
completed: tasks.filter(t => t.status === 'completed').length,
|
|
690
|
+
failed: tasks.filter(t => t.status === 'failed').length,
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
sendJson(res, 200, { tasks, groups, stats });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function handleTaskCreate(req, res) {
|
|
697
|
+
const body = await readJsonBody(req, res);
|
|
698
|
+
if (!body) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (!body.url) {
|
|
703
|
+
return sendJson(res, 400, { error: 'url_required' });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
const task = await storage.createTask({
|
|
708
|
+
url: body.url,
|
|
709
|
+
options: body.options || {},
|
|
710
|
+
});
|
|
711
|
+
sendJson(res, 201, { task });
|
|
712
|
+
} catch (error) {
|
|
713
|
+
sendJson(res, 400, { error: error.message });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function handleTaskGet(res, taskId) {
|
|
718
|
+
const task = await storage.getTask(taskId);
|
|
719
|
+
if (!task) {
|
|
720
|
+
return sendJson(res, 404, { error: 'task_not_found' });
|
|
721
|
+
}
|
|
722
|
+
return sendJson(res, 200, { task });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function handleTaskUpdate(req, res, taskId) {
|
|
726
|
+
const body = await readJsonBody(req, res);
|
|
727
|
+
if (!body) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const task = await storage.updateTask(taskId, body);
|
|
732
|
+
if (!task) {
|
|
733
|
+
return sendJson(res, 404, { error: 'task_not_found' });
|
|
734
|
+
}
|
|
735
|
+
return sendJson(res, 200, { task });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function handleTaskDelete(res, taskId) {
|
|
739
|
+
const success = await storage.deleteTask(taskId);
|
|
740
|
+
if (!success) {
|
|
741
|
+
return sendJson(res, 404, { error: 'task_not_found' });
|
|
742
|
+
}
|
|
743
|
+
return sendNoContent(res);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function handleTasksClearCompleted(res) {
|
|
747
|
+
const count = await storage.clearCompletedTasks();
|
|
748
|
+
return sendJson(res, 200, { deleted: count });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function handleTaskRetry(req, res, taskId) {
|
|
752
|
+
const task = await storage.getTask(taskId);
|
|
753
|
+
if (!task) {
|
|
754
|
+
return sendJson(res, 404, { error: 'task_not_found' });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 重置任务状态
|
|
758
|
+
const resetTask = await storage.updateTask(taskId, {
|
|
759
|
+
status: 'pending',
|
|
760
|
+
progress: 0,
|
|
761
|
+
stage: null,
|
|
762
|
+
error: null,
|
|
763
|
+
completedAt: null,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
return sendJson(res, 200, { task: resetTask });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ==================== 路由扫描 API ====================
|
|
770
|
+
|
|
771
|
+
async function handleScanRoutes(req, res) {
|
|
772
|
+
const body = await readJsonBody(req, res);
|
|
773
|
+
if (!body) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const url = body.url;
|
|
778
|
+
if (!url) {
|
|
779
|
+
return sendJson(res, 400, { error: 'url_required' });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
new URL(url);
|
|
784
|
+
} catch {
|
|
785
|
+
return sendJson(res, 400, { error: 'invalid_url' });
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
const routes = await scanWebsiteRoutes(url, body.limit || 10);
|
|
790
|
+
sendJson(res, 200, {
|
|
791
|
+
routes,
|
|
792
|
+
total: routes.length,
|
|
793
|
+
baseUrl: url,
|
|
794
|
+
});
|
|
795
|
+
} catch (error) {
|
|
796
|
+
sendJson(res, 400, { error: error.message });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function scanWebsiteRoutes(baseUrl, maxRoutes = 10) {
|
|
801
|
+
const playwright = await loadPlaywright({
|
|
802
|
+
logger: (text) => process.stderr.write(text),
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const { chromium } = playwright;
|
|
806
|
+
const browser = await chromium.launch({ headless: true });
|
|
807
|
+
const context = await browser.newContext();
|
|
808
|
+
const page = await context.newPage();
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
812
|
+
await page.waitForTimeout(1200);
|
|
813
|
+
|
|
814
|
+
// 收集所有链接
|
|
815
|
+
const routes = new Set();
|
|
816
|
+
routes.add(new URL(baseUrl).pathname);
|
|
817
|
+
|
|
818
|
+
// 获取所有链接
|
|
819
|
+
const links = await page.$$eval('a[href]', (anchors) => {
|
|
820
|
+
return anchors.map((a) => a.href);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
const baseUrlObj = new URL(baseUrl);
|
|
824
|
+
links.forEach((href) => {
|
|
825
|
+
try {
|
|
826
|
+
const linkUrl = new URL(href, baseUrl);
|
|
827
|
+
if (linkUrl.origin === baseUrlObj.origin) {
|
|
828
|
+
const pathname = linkUrl.pathname;
|
|
829
|
+
if (pathname && !routes.has(pathname)) {
|
|
830
|
+
routes.add(pathname);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch {
|
|
834
|
+
// 忽略无效链接
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// 尝试获取 sitemap
|
|
839
|
+
const sitemapRoutes = await fetchSitemapRoutes(baseUrl, maxRoutes * 5);
|
|
840
|
+
sitemapRoutes.forEach((route) => {
|
|
841
|
+
if (route && !routes.has(route)) {
|
|
842
|
+
routes.add(route);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// 排序并限制数量
|
|
847
|
+
const sortedRoutes = Array.from(routes)
|
|
848
|
+
.sort((a, b) => {
|
|
849
|
+
const depthA = a.split('/').filter(Boolean).length;
|
|
850
|
+
const depthB = b.split('/').filter(Boolean).length;
|
|
851
|
+
if (depthA !== depthB) return depthA - depthB;
|
|
852
|
+
return a.localeCompare(b);
|
|
853
|
+
})
|
|
854
|
+
.slice(0, maxRoutes);
|
|
855
|
+
|
|
856
|
+
return sortedRoutes;
|
|
857
|
+
} finally {
|
|
858
|
+
await page.close().catch(() => undefined);
|
|
859
|
+
await context.close().catch(() => undefined);
|
|
860
|
+
await browser.close().catch(() => undefined);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function fetchUrlText(targetUrl, timeoutMs = 15000) {
|
|
865
|
+
return new Promise((resolve) => {
|
|
866
|
+
let urlObj;
|
|
867
|
+
try {
|
|
868
|
+
urlObj = new URL(targetUrl);
|
|
869
|
+
} catch {
|
|
870
|
+
resolve(null);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const lib = urlObj.protocol === 'https:' ? https : http;
|
|
875
|
+
const req = lib.get(
|
|
876
|
+
{
|
|
877
|
+
hostname: urlObj.hostname,
|
|
878
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
879
|
+
path: urlObj.pathname + urlObj.search,
|
|
880
|
+
timeout: timeoutMs,
|
|
881
|
+
headers: { 'User-Agent': 'design-learn-scan' },
|
|
882
|
+
},
|
|
883
|
+
(res) => {
|
|
884
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
885
|
+
const redirectUrl = new URL(res.headers.location, urlObj).toString();
|
|
886
|
+
res.resume();
|
|
887
|
+
resolve(fetchUrlText(redirectUrl, timeoutMs));
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (res.statusCode !== 200) {
|
|
892
|
+
res.resume();
|
|
893
|
+
resolve(null);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let data = '';
|
|
898
|
+
res.on('data', (chunk) => (data += chunk));
|
|
899
|
+
res.on('end', () => resolve(data));
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
req.on('error', () => resolve(null));
|
|
904
|
+
req.on('timeout', () => {
|
|
905
|
+
req.destroy();
|
|
906
|
+
resolve(null);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function extractSitemapLocs(xml, tagName) {
|
|
912
|
+
const matches = xml.matchAll(
|
|
913
|
+
new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<loc>([\\s\\S]*?)<\\/loc>[\\s\\S]*?<\\/${tagName}>`, 'gi')
|
|
914
|
+
);
|
|
915
|
+
const items = [];
|
|
916
|
+
for (const match of matches) {
|
|
917
|
+
const raw = match[1] || '';
|
|
918
|
+
const cleaned = raw.replace(/<!\\[CDATA\\[(.*)\\]\\]>/, '$1').trim();
|
|
919
|
+
if (cleaned) items.push(cleaned);
|
|
920
|
+
}
|
|
921
|
+
return items;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function fetchRobotsSitemaps(baseUrl) {
|
|
925
|
+
try {
|
|
926
|
+
const base = new URL(baseUrl);
|
|
927
|
+
const robotsUrl = new URL('/robots.txt', base.origin).toString();
|
|
928
|
+
const text = await fetchUrlText(robotsUrl);
|
|
929
|
+
if (!text) return [];
|
|
930
|
+
return text
|
|
931
|
+
.split('\n')
|
|
932
|
+
.map((line) => line.trim())
|
|
933
|
+
.filter((line) => /^sitemap:/i.test(line))
|
|
934
|
+
.map((line) => line.split(':').slice(1).join(':').trim())
|
|
935
|
+
.filter(Boolean);
|
|
936
|
+
} catch {
|
|
937
|
+
return [];
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function fetchSitemapRoutes(baseUrl, limit) {
|
|
942
|
+
const base = new URL(baseUrl);
|
|
943
|
+
const defaultSitemaps = ['/sitemap.xml', '/wp-sitemap.xml', '/sitemap_index.xml'].map((p) =>
|
|
944
|
+
new URL(p, base.origin).toString()
|
|
945
|
+
);
|
|
946
|
+
const robotsSitemaps = await fetchRobotsSitemaps(baseUrl);
|
|
947
|
+
const queue = Array.from(new Set([...robotsSitemaps, ...defaultSitemaps]));
|
|
948
|
+
const visited = new Set();
|
|
949
|
+
const routes = new Set();
|
|
950
|
+
|
|
951
|
+
while (queue.length && routes.size < limit) {
|
|
952
|
+
const sitemapUrl = queue.shift();
|
|
953
|
+
if (!sitemapUrl || visited.has(sitemapUrl)) continue;
|
|
954
|
+
visited.add(sitemapUrl);
|
|
955
|
+
|
|
956
|
+
const xml = await fetchUrlText(sitemapUrl);
|
|
957
|
+
if (!xml) continue;
|
|
958
|
+
|
|
959
|
+
const urlLocs = extractSitemapLocs(xml, 'url');
|
|
960
|
+
for (const raw of urlLocs) {
|
|
961
|
+
if (routes.size >= limit) break;
|
|
962
|
+
try {
|
|
963
|
+
const urlObj = new URL(raw, base.origin);
|
|
964
|
+
if (urlObj.origin === base.origin) {
|
|
965
|
+
routes.add(urlObj.pathname);
|
|
966
|
+
}
|
|
967
|
+
} catch {
|
|
968
|
+
// ignore invalid
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const sitemapLocs = extractSitemapLocs(xml, 'sitemap');
|
|
973
|
+
sitemapLocs.forEach((loc) => {
|
|
974
|
+
try {
|
|
975
|
+
const urlObj = new URL(loc, base.origin);
|
|
976
|
+
if (!visited.has(urlObj.toString())) queue.push(urlObj.toString());
|
|
977
|
+
} catch {
|
|
978
|
+
// ignore invalid
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return Array.from(routes);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function handleRequest(req, res) {
|
|
987
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
988
|
+
const pathname = url.pathname;
|
|
989
|
+
|
|
990
|
+
console.log(`[http] ${req.method} ${pathname}`);
|
|
991
|
+
|
|
992
|
+
if (isMcpPath(pathname)) {
|
|
993
|
+
if (req.method === 'POST') {
|
|
994
|
+
const body = await readJsonBody(req, res);
|
|
995
|
+
if (!body) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
await mcpHandler.handleRequest(req, res, body);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
await mcpHandler.handleRequest(req, res);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (isWsPath(pathname)) {
|
|
1007
|
+
return handleWsHttpFallback(req, res);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (pathname === '/api/designs/import') {
|
|
1011
|
+
if (req.method === 'POST') {
|
|
1012
|
+
return handleImportBrowser(req, res);
|
|
1013
|
+
}
|
|
1014
|
+
return sendMethodNotAllowed(res);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (pathname.startsWith('/api/import')) {
|
|
1018
|
+
if (pathname === '/api/import/stream') {
|
|
1019
|
+
if (req.method === 'GET') {
|
|
1020
|
+
return handleImportStream(req, res);
|
|
1021
|
+
}
|
|
1022
|
+
return sendMethodNotAllowed(res);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (pathname === '/api/import/jobs') {
|
|
1026
|
+
if (req.method === 'GET') {
|
|
1027
|
+
return handleImportJobs(res);
|
|
1028
|
+
}
|
|
1029
|
+
return sendMethodNotAllowed(res);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (pathname.startsWith('/api/import/jobs/')) {
|
|
1033
|
+
if (req.method === 'GET') {
|
|
1034
|
+
const jobId = pathname.split('/').pop();
|
|
1035
|
+
return handleImportJob(res, jobId);
|
|
1036
|
+
}
|
|
1037
|
+
return sendMethodNotAllowed(res);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (pathname === '/api/import/browser') {
|
|
1041
|
+
if (req.method === 'POST') {
|
|
1042
|
+
return handleImportBrowser(req, res);
|
|
1043
|
+
}
|
|
1044
|
+
return sendMethodNotAllowed(res);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (pathname === '/api/import/url') {
|
|
1048
|
+
if (req.method === 'POST') {
|
|
1049
|
+
return handleImportUrl(req, res);
|
|
1050
|
+
}
|
|
1051
|
+
return sendMethodNotAllowed(res);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (pathname === '/api/config') {
|
|
1056
|
+
if (req.method === 'GET') {
|
|
1057
|
+
return handleConfigGet(res);
|
|
1058
|
+
}
|
|
1059
|
+
if (req.method === 'PUT') {
|
|
1060
|
+
return handleConfigPut(req, res);
|
|
1061
|
+
}
|
|
1062
|
+
return sendMethodNotAllowed(res);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (pathname === '/api/designs') {
|
|
1066
|
+
if (req.method === 'GET') {
|
|
1067
|
+
return handleDesignList(res, url);
|
|
1068
|
+
}
|
|
1069
|
+
if (req.method === 'POST') {
|
|
1070
|
+
return handleDesignCreate(req, res);
|
|
1071
|
+
}
|
|
1072
|
+
return sendMethodNotAllowed(res);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (pathname.startsWith('/api/designs/')) {
|
|
1076
|
+
const designId = pathname.split('/').pop();
|
|
1077
|
+
if (!designId) {
|
|
1078
|
+
return sendJson(res, 400, { error: 'design_id_required' });
|
|
1079
|
+
}
|
|
1080
|
+
if (req.method === 'GET') {
|
|
1081
|
+
return handleDesignGet(res, designId);
|
|
1082
|
+
}
|
|
1083
|
+
if (req.method === 'PATCH') {
|
|
1084
|
+
return handleDesignPatch(req, res, designId);
|
|
1085
|
+
}
|
|
1086
|
+
if (req.method === 'DELETE') {
|
|
1087
|
+
return handleDesignDelete(res, designId);
|
|
1088
|
+
}
|
|
1089
|
+
return sendMethodNotAllowed(res);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (pathname.startsWith('/api/versions/')) {
|
|
1093
|
+
const versionId = pathname.split('/').pop();
|
|
1094
|
+
if (!versionId) {
|
|
1095
|
+
return sendJson(res, 400, { error: 'version_id_required' });
|
|
1096
|
+
}
|
|
1097
|
+
if (req.method === 'GET') {
|
|
1098
|
+
return handleVersionGet(res, versionId);
|
|
1099
|
+
}
|
|
1100
|
+
if (req.method === 'PATCH' || req.method === 'PUT') {
|
|
1101
|
+
return handleVersionPatch(req, res, versionId);
|
|
1102
|
+
}
|
|
1103
|
+
return sendMethodNotAllowed(res);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (pathname === '/api/snapshots/import') {
|
|
1107
|
+
if (req.method === 'POST') {
|
|
1108
|
+
return handleImportBrowser(req, res);
|
|
1109
|
+
}
|
|
1110
|
+
return sendMethodNotAllowed(res);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (pathname === '/api/snapshots') {
|
|
1114
|
+
if (req.method === 'GET') {
|
|
1115
|
+
return handleSnapshotsList(res, url);
|
|
1116
|
+
}
|
|
1117
|
+
return sendMethodNotAllowed(res);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (pathname === '/api/uipro/domains') {
|
|
1121
|
+
if (req.method === 'GET') {
|
|
1122
|
+
return handleUiproDomains(res);
|
|
1123
|
+
}
|
|
1124
|
+
return sendMethodNotAllowed(res);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (pathname === '/api/uipro/stacks') {
|
|
1128
|
+
if (req.method === 'GET') {
|
|
1129
|
+
return handleUiproStacks(res);
|
|
1130
|
+
}
|
|
1131
|
+
return sendMethodNotAllowed(res);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (pathname === '/api/uipro/search') {
|
|
1135
|
+
if (req.method === 'GET') {
|
|
1136
|
+
return handleUiproSearch(res, url);
|
|
1137
|
+
}
|
|
1138
|
+
return sendMethodNotAllowed(res);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (pathname === '/api/uipro/search-stack') {
|
|
1142
|
+
if (req.method === 'GET') {
|
|
1143
|
+
return handleUiproSearchStack(res, url);
|
|
1144
|
+
}
|
|
1145
|
+
return sendMethodNotAllowed(res);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (pathname === '/api/uipro/browse') {
|
|
1149
|
+
if (req.method === 'GET') {
|
|
1150
|
+
return handleUiproBrowse(res, url);
|
|
1151
|
+
}
|
|
1152
|
+
return sendMethodNotAllowed(res);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (pathname === '/api/uipro/suggest') {
|
|
1156
|
+
if (req.method === 'GET') {
|
|
1157
|
+
return handleUiproSuggest(res, url);
|
|
1158
|
+
}
|
|
1159
|
+
return sendMethodNotAllowed(res);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (pathname === '/api/uipro/browse-stack') {
|
|
1163
|
+
if (req.method === 'GET') {
|
|
1164
|
+
return handleUiproBrowseStack(res, url);
|
|
1165
|
+
}
|
|
1166
|
+
return sendMethodNotAllowed(res);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (pathname === '/api/uipro/suggest-stack') {
|
|
1170
|
+
if (req.method === 'GET') {
|
|
1171
|
+
return handleUiproSuggestStack(res, url);
|
|
1172
|
+
}
|
|
1173
|
+
return sendMethodNotAllowed(res);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (pathname.startsWith('/api/snapshots/')) {
|
|
1177
|
+
const snapshotId = pathname.split('/').pop();
|
|
1178
|
+
if (!snapshotId) {
|
|
1179
|
+
return sendJson(res, 400, { error: 'snapshot_id_required' });
|
|
1180
|
+
}
|
|
1181
|
+
if (req.method === 'GET') {
|
|
1182
|
+
return handleSnapshotGet(res, snapshotId);
|
|
1183
|
+
}
|
|
1184
|
+
if (req.method === 'DELETE') {
|
|
1185
|
+
return handleSnapshotDelete(res, snapshotId);
|
|
1186
|
+
}
|
|
1187
|
+
return sendMethodNotAllowed(res);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (pathname === '/api/previews') {
|
|
1191
|
+
if (req.method === 'POST') {
|
|
1192
|
+
return handlePreviewEnqueue(req, res);
|
|
1193
|
+
}
|
|
1194
|
+
return sendMethodNotAllowed(res);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (pathname === '/api/previews/jobs') {
|
|
1198
|
+
if (req.method === 'GET') {
|
|
1199
|
+
return handlePreviewJobs(res);
|
|
1200
|
+
}
|
|
1201
|
+
return sendMethodNotAllowed(res);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if (pathname.startsWith('/api/previews/jobs/')) {
|
|
1205
|
+
const jobId = pathname.split('/').pop();
|
|
1206
|
+
if (!jobId) {
|
|
1207
|
+
return sendJson(res, 400, { error: 'preview_job_id_required' });
|
|
1208
|
+
}
|
|
1209
|
+
if (req.method === 'GET') {
|
|
1210
|
+
return handlePreviewJob(res, jobId);
|
|
1211
|
+
}
|
|
1212
|
+
return sendMethodNotAllowed(res);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (pathname.startsWith('/api/previews/')) {
|
|
1216
|
+
const componentId = pathname.split('/').pop();
|
|
1217
|
+
if (!componentId) {
|
|
1218
|
+
return sendJson(res, 400, { error: 'component_id_required' });
|
|
1219
|
+
}
|
|
1220
|
+
if (req.method === 'GET') {
|
|
1221
|
+
return handlePreviewGet(res, componentId);
|
|
1222
|
+
}
|
|
1223
|
+
return sendMethodNotAllowed(res);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 任务管理路由
|
|
1227
|
+
if (pathname === '/api/tasks') {
|
|
1228
|
+
if (req.method === 'GET') {
|
|
1229
|
+
return handleTasksList(res, url);
|
|
1230
|
+
}
|
|
1231
|
+
if (req.method === 'POST') {
|
|
1232
|
+
return handleTaskCreate(req, res);
|
|
1233
|
+
}
|
|
1234
|
+
return sendMethodNotAllowed(res);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (pathname === '/api/tasks/clear-completed') {
|
|
1238
|
+
if (req.method === 'DELETE') {
|
|
1239
|
+
return handleTasksClearCompleted(res);
|
|
1240
|
+
}
|
|
1241
|
+
return sendMethodNotAllowed(res);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (pathname.startsWith('/api/tasks/')) {
|
|
1245
|
+
const taskId = pathname.split('/').pop();
|
|
1246
|
+
if (!taskId) {
|
|
1247
|
+
return sendJson(res, 400, { error: 'task_id_required' });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (taskId === 'clear-completed') {
|
|
1251
|
+
return sendMethodNotAllowed(res);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (taskId === 'retry' && req.method === 'POST') {
|
|
1255
|
+
const actualTaskId = pathname.split('/').slice(-2, -1)[0];
|
|
1256
|
+
if (!actualTaskId) {
|
|
1257
|
+
return sendJson(res, 400, { error: 'task_id_required' });
|
|
1258
|
+
}
|
|
1259
|
+
return handleTaskRetry(req, res, actualTaskId);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (req.method === 'GET') {
|
|
1263
|
+
return handleTaskGet(res, taskId);
|
|
1264
|
+
}
|
|
1265
|
+
if (req.method === 'PATCH') {
|
|
1266
|
+
return handleTaskUpdate(req, res, taskId);
|
|
1267
|
+
}
|
|
1268
|
+
if (req.method === 'DELETE') {
|
|
1269
|
+
return handleTaskDelete(res, taskId);
|
|
1270
|
+
}
|
|
1271
|
+
return sendMethodNotAllowed(res);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// 路由扫描路由
|
|
1275
|
+
if (pathname === '/api/scan-routes') {
|
|
1276
|
+
if (req.method === 'POST') {
|
|
1277
|
+
return handleScanRoutes(req, res);
|
|
1278
|
+
}
|
|
1279
|
+
return sendMethodNotAllowed(res);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const route = findRoute(req.method, pathname);
|
|
1283
|
+
if (route) {
|
|
1284
|
+
return route.handler(req, res);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return sendJson(res, 404, { error: 'not_found', path: pathname });
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function readJsonBody(req, res) {
|
|
1291
|
+
return new Promise((resolve) => {
|
|
1292
|
+
let data = '';
|
|
1293
|
+
req.on('data', (chunk) => {
|
|
1294
|
+
data += chunk;
|
|
1295
|
+
});
|
|
1296
|
+
req.on('end', () => {
|
|
1297
|
+
if (!data) {
|
|
1298
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1299
|
+
res.end(JSON.stringify({ error: 'empty_body' }));
|
|
1300
|
+
resolve(null);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
try {
|
|
1304
|
+
resolve(JSON.parse(data));
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1307
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
1308
|
+
resolve(null);
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function handleWebSocketUpgrade(req, socket) {
|
|
1315
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1316
|
+
if (!isWsPath(url.pathname)) {
|
|
1317
|
+
socket.destroy();
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const key = req.headers['sec-websocket-key'];
|
|
1322
|
+
if (!key) {
|
|
1323
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1324
|
+
socket.destroy();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
1329
|
+
const headers = [
|
|
1330
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
1331
|
+
'Upgrade: websocket',
|
|
1332
|
+
'Connection: Upgrade',
|
|
1333
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
1334
|
+
'\r\n',
|
|
1335
|
+
];
|
|
1336
|
+
|
|
1337
|
+
socket.write(headers.join('\r\n'));
|
|
1338
|
+
|
|
1339
|
+
const closeTimer = setTimeout(() => {
|
|
1340
|
+
socket.end();
|
|
1341
|
+
}, WS_CLOSE_DELAY_MS);
|
|
1342
|
+
|
|
1343
|
+
socket.on('close', () => clearTimeout(closeTimer));
|
|
1344
|
+
socket.on('error', (err) => console.error(`[ws] ${err.message}`));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const server = http.createServer((req, res) => {
|
|
1348
|
+
handleRequest(req, res).catch((error) => {
|
|
1349
|
+
console.error('[http] handler error', error);
|
|
1350
|
+
if (!res.headersSent) {
|
|
1351
|
+
sendJson(res, 500, { error: 'internal_error' });
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
server.on('upgrade', (req, socket) => {
|
|
1357
|
+
try {
|
|
1358
|
+
handleWebSocketUpgrade(req, socket);
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
console.error('[ws] upgrade error', error);
|
|
1361
|
+
socket.destroy();
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
server.on('clientError', (err, socket) => {
|
|
1366
|
+
console.error('[http] client error', err.message);
|
|
1367
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
server.listen(DEFAULT_PORT, () => {
|
|
1371
|
+
console.log(`[design-learn-server] listening on http://localhost:${DEFAULT_PORT}`);
|
|
1372
|
+
console.log(`[design-learn-server] data dir: ${storage.dataDir}`);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
function shutdown(signal) {
|
|
1376
|
+
console.log(`[design-learn-server] received ${signal}, shutting down`);
|
|
1377
|
+
mcpHandler.close().catch((error) => console.error('[mcp] close error', error));
|
|
1378
|
+
extractionPipeline.close();
|
|
1379
|
+
previewPipeline.close();
|
|
1380
|
+
storage.close();
|
|
1381
|
+
server.close(() => process.exit(0));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1385
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|