@lightcone-ai/daemon 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp-servers/official/ai-image-gen/index.js +277 -0
- package/mcp-servers/official/ai-image-gen/manifest.json +19 -0
- package/mcp-servers/official/audience-research/index.js +383 -0
- package/mcp-servers/official/audience-research/manifest.json +18 -0
- package/mcp-servers/official/hook-pattern-library/index.js +417 -0
- package/mcp-servers/official/hook-pattern-library/manifest.json +18 -0
- package/mcp-servers/official/image-search-licensed/index.js +309 -0
- package/mcp-servers/official/image-search-licensed/manifest.json +18 -0
- package/mcp-servers/official/keyword-research/index.js +292 -0
- package/mcp-servers/official/keyword-research/manifest.json +19 -0
- package/mcp-servers/official/platform-policy-db/index.js +316 -0
- package/mcp-servers/official/platform-policy-db/manifest.json +18 -0
- package/mcp-servers/official/publish-window-optimizer/index.js +296 -0
- package/mcp-servers/official/publish-window-optimizer/manifest.json +17 -0
- package/mcp-servers/publisher/adapters/publisher-adapter.js +37 -0
- package/mcp-servers/publisher/adapters/xhs.js +103 -17
- package/mcp-servers/publisher/index.js +123 -25
- package/mcp-servers/publisher/official-tool-client.js +171 -0
- package/mcp-servers/publisher/precheck.js +240 -0
- package/package.json +1 -1
- package/src/chat-bridge.js +614 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const SOURCE_RISK = 'untrusted';
|
|
7
|
+
|
|
8
|
+
const DOUBAO_DEFAULT_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3';
|
|
9
|
+
const DALLE_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
10
|
+
|
|
11
|
+
const DOUBAO_API_KEY = String(process.env.DOUBAO_API_KEY ?? '').trim();
|
|
12
|
+
const DOUBAO_BASE_URL = String(process.env.DOUBAO_BASE_URL ?? DOUBAO_DEFAULT_BASE_URL).trim();
|
|
13
|
+
const DOUBAO_IMAGE_MODEL = String(process.env.DOUBAO_IMAGE_MODEL ?? 'doubao-seedream-3-0-t2i-250415').trim();
|
|
14
|
+
|
|
15
|
+
const DALLE_API_KEY = String(process.env.DALL_E_API_KEY ?? process.env.OPENAI_API_KEY ?? '').trim();
|
|
16
|
+
const DALLE_BASE_URL = String(process.env.DALL_E_BASE_URL ?? process.env.OPENAI_BASE_URL ?? DALLE_DEFAULT_BASE_URL).trim();
|
|
17
|
+
const DALLE_IMAGE_MODEL = String(process.env.DALL_E_IMAGE_MODEL ?? 'gpt-image-1').trim();
|
|
18
|
+
|
|
19
|
+
const REQUEST_TIMEOUT_MS = clampInt(process.env.AI_IMAGE_GEN_TIMEOUT_MS, 1000, 60000, 20000);
|
|
20
|
+
|
|
21
|
+
function clampInt(value, min, max, fallback) {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (!Number.isFinite(n)) return fallback;
|
|
24
|
+
return Math.max(min, Math.min(max, Math.trunc(n)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joinUrl(baseUrl, path) {
|
|
28
|
+
const trimmedBase = String(baseUrl ?? '').trim();
|
|
29
|
+
const normalizedBase = trimmedBase.endsWith('/') ? trimmedBase.slice(0, -1) : trimmedBase;
|
|
30
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
31
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseSize(raw) {
|
|
35
|
+
const text = String(raw ?? '1024x1024').trim().toLowerCase();
|
|
36
|
+
const match = text.match(/^(\d{2,4})x(\d{2,4})$/);
|
|
37
|
+
if (!match) return { size: '1024x1024', width: 1024, height: 1024 };
|
|
38
|
+
const width = clampInt(match[1], 64, 2048, 1024);
|
|
39
|
+
const height = clampInt(match[2], 64, 2048, 1024);
|
|
40
|
+
return { size: `${width}x${height}`, width, height };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hashString(text) {
|
|
44
|
+
let hash = 2166136261;
|
|
45
|
+
const raw = String(text ?? '');
|
|
46
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
47
|
+
hash ^= raw.charCodeAt(i);
|
|
48
|
+
hash = Math.imul(hash, 16777619);
|
|
49
|
+
}
|
|
50
|
+
return hash >>> 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseJsonMaybe(text) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function postJson(url, headers, body) {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers,
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
const payload = parseJsonMaybe(text) ?? { raw: text.slice(0, 600) };
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`http_${res.status}:${payload?.error?.message ?? text.slice(0, 200)}`);
|
|
75
|
+
}
|
|
76
|
+
return payload;
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toImageList(payload) {
|
|
83
|
+
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
|
84
|
+
return rows
|
|
85
|
+
.map((row, index) => {
|
|
86
|
+
const url = String(row?.url ?? '').trim();
|
|
87
|
+
const b64 = String(row?.b64_json ?? '').trim();
|
|
88
|
+
const imageUrl = url || (b64 ? `data:image/png;base64,${b64}` : '');
|
|
89
|
+
if (!imageUrl) return null;
|
|
90
|
+
return {
|
|
91
|
+
id: String(row?.id ?? `img-${index + 1}`),
|
|
92
|
+
url: imageUrl,
|
|
93
|
+
revised_prompt: String(row?.revised_prompt ?? '').trim() || null,
|
|
94
|
+
source_risk: SOURCE_RISK,
|
|
95
|
+
};
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function generateOpenAiCompatible({
|
|
101
|
+
provider,
|
|
102
|
+
apiKey,
|
|
103
|
+
baseUrl,
|
|
104
|
+
model,
|
|
105
|
+
prompt,
|
|
106
|
+
size,
|
|
107
|
+
n,
|
|
108
|
+
quality,
|
|
109
|
+
style,
|
|
110
|
+
}) {
|
|
111
|
+
const payload = {
|
|
112
|
+
model,
|
|
113
|
+
prompt,
|
|
114
|
+
size,
|
|
115
|
+
n,
|
|
116
|
+
response_format: 'url',
|
|
117
|
+
};
|
|
118
|
+
if (quality) payload.quality = quality;
|
|
119
|
+
if (style) payload.style = style;
|
|
120
|
+
|
|
121
|
+
const data = await postJson(
|
|
122
|
+
joinUrl(baseUrl, '/images/generations'),
|
|
123
|
+
{
|
|
124
|
+
Authorization: `Bearer ${apiKey}`,
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
},
|
|
127
|
+
payload
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const images = toImageList(data);
|
|
131
|
+
if (images.length === 0) {
|
|
132
|
+
throw new Error(`${provider}_empty_images`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
provider,
|
|
137
|
+
model,
|
|
138
|
+
images,
|
|
139
|
+
raw: data,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function providerOrder(requestedProvider) {
|
|
144
|
+
const requested = String(requestedProvider ?? 'auto').trim().toLowerCase();
|
|
145
|
+
if (requested === 'doubao') return ['doubao'];
|
|
146
|
+
if (requested === 'dall-e' || requested === 'dalle') return ['dall-e'];
|
|
147
|
+
return ['doubao', 'dall-e'];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getProviderCredential(provider) {
|
|
151
|
+
if (provider === 'doubao') {
|
|
152
|
+
return {
|
|
153
|
+
apiKey: DOUBAO_API_KEY,
|
|
154
|
+
baseUrl: DOUBAO_BASE_URL,
|
|
155
|
+
model: DOUBAO_IMAGE_MODEL,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
apiKey: DALLE_API_KEY,
|
|
160
|
+
baseUrl: DALLE_BASE_URL,
|
|
161
|
+
model: DALLE_IMAGE_MODEL,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildMockImages({ prompt, size, n }) {
|
|
166
|
+
const count = clampInt(n, 1, 4, 1);
|
|
167
|
+
const parsed = parseSize(size);
|
|
168
|
+
const images = [];
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < count; i += 1) {
|
|
171
|
+
const seed = hashString(`${prompt}|${parsed.size}|${i + 1}`);
|
|
172
|
+
images.push({
|
|
173
|
+
id: `mock-${i + 1}`,
|
|
174
|
+
url: `https://picsum.photos/seed/lightcone-ai-${seed}/${parsed.width}/${parsed.height}`,
|
|
175
|
+
revised_prompt: null,
|
|
176
|
+
source_risk: SOURCE_RISK,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return images;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function generateImages(input) {
|
|
184
|
+
const prompt = String(input.prompt ?? '').trim();
|
|
185
|
+
if (!prompt) throw new Error('prompt_required');
|
|
186
|
+
|
|
187
|
+
const parsedSize = parseSize(input.size);
|
|
188
|
+
const n = clampInt(input.count, 1, 4, 1);
|
|
189
|
+
const quality = input.quality ? String(input.quality).trim() : '';
|
|
190
|
+
const style = input.style ? String(input.style).trim() : '';
|
|
191
|
+
|
|
192
|
+
const attempts = providerOrder(input.provider);
|
|
193
|
+
const errors = [];
|
|
194
|
+
|
|
195
|
+
for (const provider of attempts) {
|
|
196
|
+
const credential = getProviderCredential(provider);
|
|
197
|
+
if (!credential.apiKey) {
|
|
198
|
+
errors.push(`${provider}_missing_api_key`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const generated = await generateOpenAiCompatible({
|
|
203
|
+
provider,
|
|
204
|
+
apiKey: credential.apiKey,
|
|
205
|
+
baseUrl: credential.baseUrl,
|
|
206
|
+
model: credential.model,
|
|
207
|
+
prompt,
|
|
208
|
+
size: parsedSize.size,
|
|
209
|
+
n,
|
|
210
|
+
quality,
|
|
211
|
+
style,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
provider_requested: String(input.provider ?? 'auto').trim().toLowerCase() || 'auto',
|
|
216
|
+
provider_used: generated.provider,
|
|
217
|
+
model_used: generated.model,
|
|
218
|
+
size: parsedSize.size,
|
|
219
|
+
prompt,
|
|
220
|
+
images: generated.images,
|
|
221
|
+
source_risk: SOURCE_RISK,
|
|
222
|
+
fallback_reason: null,
|
|
223
|
+
timestamp: new Date().toISOString(),
|
|
224
|
+
};
|
|
225
|
+
} catch (error) {
|
|
226
|
+
errors.push(`${provider}_error:${error.message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
provider_requested: String(input.provider ?? 'auto').trim().toLowerCase() || 'auto',
|
|
232
|
+
provider_used: 'mock',
|
|
233
|
+
model_used: 'mock-image-provider',
|
|
234
|
+
size: parsedSize.size,
|
|
235
|
+
prompt,
|
|
236
|
+
images: buildMockImages({ prompt, size: parsedSize.size, n }),
|
|
237
|
+
source_risk: SOURCE_RISK,
|
|
238
|
+
fallback_reason: errors.length > 0 ? errors[0] : 'no_provider_available',
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function toolError(error) {
|
|
244
|
+
return {
|
|
245
|
+
isError: true,
|
|
246
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const server = new McpServer({ name: 'official-ai-image-gen', version: '0.1.0' });
|
|
251
|
+
|
|
252
|
+
server.tool(
|
|
253
|
+
'ai_image_gen',
|
|
254
|
+
'Generate AI images and return URL list with source_risk=untrusted.',
|
|
255
|
+
{
|
|
256
|
+
prompt: z.string().min(1).describe('Image generation prompt.'),
|
|
257
|
+
provider: z.enum(['auto', 'doubao', 'dall-e']).optional().describe('Image provider. auto = doubao first, then dall-e.'),
|
|
258
|
+
size: z.string().optional().describe('Image size, e.g. 1024x1024.'),
|
|
259
|
+
count: z.number().int().min(1).max(4).optional().describe('How many images to generate, default 1.'),
|
|
260
|
+
quality: z.string().optional().describe('Provider-specific quality, e.g. hd/standard.'),
|
|
261
|
+
style: z.string().optional().describe('Provider-specific style option.'),
|
|
262
|
+
},
|
|
263
|
+
async (input) => {
|
|
264
|
+
try {
|
|
265
|
+
const payload = await generateImages(input);
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return toolError(error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const transport = new StdioServerTransport();
|
|
276
|
+
await server.connect(transport);
|
|
277
|
+
console.error('[official-ai-image-gen] MCP Server started');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ai-image-gen",
|
|
3
|
+
"name": "Official AI Image Gen MCP",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "ai_image_gen", "classification": "cacheable" }
|
|
9
|
+
],
|
|
10
|
+
"smoke_test": {
|
|
11
|
+
"tool": "ai_image_gen",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"prompt": "简洁风格的蓝色科技封面,留白,16:9",
|
|
14
|
+
"provider": "auto",
|
|
15
|
+
"size": "1024x1024",
|
|
16
|
+
"count": 1
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { startFixtureServer } from '../../official-common/server.js';
|
|
4
|
+
|
|
5
|
+
const FIXTURE_META = Object.freeze({
|
|
6
|
+
mode: 'fixture',
|
|
7
|
+
as_of: '2026-04-29',
|
|
8
|
+
capability: 'audience-research',
|
|
9
|
+
disclaimer: 'Audience profile is estimated from fixture fan snapshots + public benchmark assumptions, not live platform BI exports.',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PLATFORM_ALIAS = Object.freeze({
|
|
13
|
+
xiaohongshu: 'xhs',
|
|
14
|
+
redbook: 'xhs',
|
|
15
|
+
douyin: 'douyin',
|
|
16
|
+
tiktok_cn: 'douyin',
|
|
17
|
+
wechat: 'wechat-mp',
|
|
18
|
+
wechat_mp: 'wechat-mp',
|
|
19
|
+
wechatmp: 'wechat-mp',
|
|
20
|
+
gzh: 'wechat-mp',
|
|
21
|
+
'\u516c\u4f17\u53f7': 'wechat-mp',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const PLATFORM_BENCHMARKS = Object.freeze({
|
|
25
|
+
xhs: Object.freeze({
|
|
26
|
+
default_audience: {
|
|
27
|
+
age_range: '25-34',
|
|
28
|
+
gender_skew: '\u5973\u6027\u4e3b\u5bfc',
|
|
29
|
+
geo_focus: ['\u4e0a\u6d77', '\u676d\u5dde', '\u6df1\u5733', '\u5317\u4eac'],
|
|
30
|
+
occupation_tags: ['\u767d\u9886', '\u81ea\u7531\u804c\u4e1a', '\u5b66\u751f'],
|
|
31
|
+
pain_points: ['\u9009\u9898\u540c\u8d28\u5316', '\u7b14\u8bb0\u8f6c\u5316\u4f4e', '\u62cd\u6444\u6267\u884c\u6210\u672c\u9ad8'],
|
|
32
|
+
aspirations: ['\u7a33\u5b9a\u6da8\u7c89', '\u5efa\u7acb\u4e13\u4e1a\u4eba\u8bbe', '\u83b7\u5f97\u54c1\u724c\u5408\u4f5c'],
|
|
33
|
+
},
|
|
34
|
+
pools: {
|
|
35
|
+
cities: ['\u4e0a\u6d77', '\u676d\u5dde', '\u82cf\u5dde', '\u6df1\u5733', '\u6210\u90fd', '\u5317\u4eac', '\u5e7f\u5dde'],
|
|
36
|
+
occupations: ['\u767d\u9886', '\u5185\u5bb9\u8fd0\u8425', '\u8bbe\u8ba1\u5e08', '\u5b66\u751f', '\u81ea\u7531\u804c\u4e1a'],
|
|
37
|
+
pain_points: ['\u9009\u9898\u540c\u8d28\u5316', '\u5728\u7ebf\u7d22\u5c11', '\u66dd\u5149\u4e0d\u7a33\u5b9a', '\u62cd\u6444\u6548\u7387\u4f4e'],
|
|
38
|
+
aspirations: ['\u589e\u52a0\u54c1\u724c\u4fe1\u4efb', '\u63d0\u9ad8\u79c1\u57df\u6210\u4ea4', '\u5f62\u6210\u56fa\u5b9a\u4eba\u8bbe', '\u5185\u5bb9\u53ef\u590d\u7528'],
|
|
39
|
+
female_ratio: 0.68,
|
|
40
|
+
age_peak: 29,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
douyin: Object.freeze({
|
|
44
|
+
default_audience: {
|
|
45
|
+
age_range: '18-34',
|
|
46
|
+
gender_skew: '\u5747\u8861',
|
|
47
|
+
geo_focus: ['\u5317\u4eac', '\u5e7f\u5dde', '\u6210\u90fd', '\u6b66\u6c49'],
|
|
48
|
+
occupation_tags: ['\u5e74\u8f7b\u767d\u9886', '\u5b66\u751f', '\u4e2a\u4f53\u7ecf\u8425\u8005'],
|
|
49
|
+
pain_points: ['\u5b8c\u64ad\u7387\u4e0d\u7a33\u5b9a', '\u811a\u672c\u751f\u786c', '\u8d77\u53f7\u6210\u672c\u9ad8'],
|
|
50
|
+
aspirations: ['\u7206\u6b3e\u7387\u63d0\u5347', '\u4f18\u5316\u8d26\u53f7\u6807\u7b7e', '\u83b7\u5f97\u5546\u5355\u7ebf\u7d22'],
|
|
51
|
+
},
|
|
52
|
+
pools: {
|
|
53
|
+
cities: ['\u5317\u4eac', '\u5e7f\u5dde', '\u6df1\u5733', '\u91cd\u5e86', '\u6210\u90fd', '\u897f\u5b89', '\u957f\u6c99'],
|
|
54
|
+
occupations: ['\u5b66\u751f', '\u9500\u552e', '\u5e74\u8f7b\u767d\u9886', '\u4e3b\u64ad', '\u4e2a\u4f53\u7ecf\u8425\u8005'],
|
|
55
|
+
pain_points: ['\u5b8c\u64ad\u7387\u4e0d\u7a33\u5b9a', '\u8bc4\u8bba\u8f6c\u5316\u4f4e', '\u7d20\u6750\u7f3a\u4e4f', '\u66dd\u5149\u6ce2\u52a8\u5927'],
|
|
56
|
+
aspirations: ['\u63d0\u9ad8\u81ea\u7136\u6d41\u91cf', '\u589e\u52a0\u76f4\u64ad\u8fdb\u623f', '\u7a33\u5b9a\u7c89\u4e1d\u7c98\u6027', '\u63d0\u5347\u8d26\u53f7\u6743\u91cd'],
|
|
57
|
+
female_ratio: 0.52,
|
|
58
|
+
age_peak: 27,
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
'wechat-mp': Object.freeze({
|
|
62
|
+
default_audience: {
|
|
63
|
+
age_range: '25-44',
|
|
64
|
+
gender_skew: '\u5747\u8861',
|
|
65
|
+
geo_focus: ['\u5317\u4eac', '\u4e0a\u6d77', '\u5e7f\u5dde', '\u6df1\u5733'],
|
|
66
|
+
occupation_tags: ['\u804c\u573a\u7ba1\u7406\u8005', '\u4ea7\u54c1\u7ecf\u7406', '\u4e2d\u5c0f\u4f01\u4e1a\u4e3b'],
|
|
67
|
+
pain_points: ['\u9605\u8bfb\u6253\u5f00\u7387\u4e0b\u6ed1', '\u6587\u7ae0\u5b8c\u8bfb\u7387\u4f4e', '\u79c1\u57df\u8f6c\u5316\u65ad\u5c42'],
|
|
68
|
+
aspirations: ['\u5efa\u7acb\u7a33\u5b9a\u793e\u7fa4', '\u63d0\u9ad8\u590d\u8d2d\u7387', '\u63d0\u9ad8\u4e13\u4e1a\u5f71\u54cd\u529b'],
|
|
69
|
+
},
|
|
70
|
+
pools: {
|
|
71
|
+
cities: ['\u5317\u4eac', '\u4e0a\u6d77', '\u6df1\u5733', '\u5e7f\u5dde', '\u676d\u5dde', '\u6b66\u6c49'],
|
|
72
|
+
occupations: ['\u804c\u573a\u7ba1\u7406\u8005', '\u4ea7\u54c1\u7ecf\u7406', '\u4f01\u4e1a\u670d\u52a1', '\u8fd0\u8425\u8d1f\u8d23\u4eba', '\u4e2d\u5c0f\u4f01\u4e1a\u4e3b'],
|
|
73
|
+
pain_points: ['\u9605\u8bfb\u6253\u5f00\u7387\u4e0b\u6ed1', '\u9009\u9898\u8001\u5316', '\u9500\u552e\u627f\u63a5\u65ad\u5c42', '\u66f4\u65b0\u8282\u594f\u4e0d\u7a33\u5b9a'],
|
|
74
|
+
aspirations: ['\u63d0\u5347\u4e13\u4e1a\u8ba4\u77e5', '\u589e\u5f3a\u79c1\u57df\u8f6c\u5316', '\u5b9e\u73b0\u8fde\u8f7d\u5316\u8f93\u51fa', '\u5f62\u6210\u9ad8\u4ef7\u503c\u54a8\u8be2\u7ebf\u7d22'],
|
|
75
|
+
female_ratio: 0.5,
|
|
76
|
+
age_peak: 33,
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const CSV_FIELD_ALIASES = Object.freeze({
|
|
82
|
+
age: 'age',
|
|
83
|
+
age_value: 'age',
|
|
84
|
+
age_range: 'age',
|
|
85
|
+
gender: 'gender',
|
|
86
|
+
sex: 'gender',
|
|
87
|
+
city: 'city',
|
|
88
|
+
geo: 'city',
|
|
89
|
+
location: 'city',
|
|
90
|
+
occupation: 'occupation',
|
|
91
|
+
job: 'occupation',
|
|
92
|
+
pain_point: 'pain_point',
|
|
93
|
+
painpoint: 'pain_point',
|
|
94
|
+
challenge: 'pain_point',
|
|
95
|
+
aspiration: 'aspiration',
|
|
96
|
+
goal: 'aspiration',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function normalizePlatform(value, accountId = '') {
|
|
100
|
+
const raw = String(value ?? '').trim().toLowerCase();
|
|
101
|
+
if (raw) return PLATFORM_ALIAS[raw] ?? raw;
|
|
102
|
+
|
|
103
|
+
const id = String(accountId ?? '').toLowerCase();
|
|
104
|
+
if (id.includes('douyin')) return 'douyin';
|
|
105
|
+
if (id.includes('wechat') || id.includes('gzh') || id.includes('mp')) return 'wechat-mp';
|
|
106
|
+
return 'xhs';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clampInt(value, min, max, fallback) {
|
|
110
|
+
const n = Number(value);
|
|
111
|
+
if (!Number.isFinite(n)) return fallback;
|
|
112
|
+
return Math.max(min, Math.min(max, Math.trunc(n)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function deterministicRatio(seed, salt) {
|
|
116
|
+
const text = `${seed}#${salt}`;
|
|
117
|
+
let hash = 2166136261;
|
|
118
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
119
|
+
hash ^= text.charCodeAt(i);
|
|
120
|
+
hash = Math.imul(hash, 16777619);
|
|
121
|
+
}
|
|
122
|
+
return (hash >>> 0) / 4294967295;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseAge(value) {
|
|
126
|
+
const text = String(value ?? '').trim();
|
|
127
|
+
if (!text) return null;
|
|
128
|
+
const num = Number(text);
|
|
129
|
+
if (Number.isFinite(num)) return Math.max(13, Math.min(70, Math.round(num)));
|
|
130
|
+
|
|
131
|
+
const match = text.match(/(\d{2})/);
|
|
132
|
+
if (!match) return null;
|
|
133
|
+
return Math.max(13, Math.min(70, Number(match[1])));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeGender(value) {
|
|
137
|
+
const raw = String(value ?? '').trim().toLowerCase();
|
|
138
|
+
if (!raw) return 'unknown';
|
|
139
|
+
if (raw === 'f' || raw.includes('female') || raw.includes('\u5973')) return 'female';
|
|
140
|
+
if (raw === 'm' || raw.includes('male') || raw.includes('\u7537')) return 'male';
|
|
141
|
+
return 'unknown';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeToken(value) {
|
|
145
|
+
const text = String(value ?? '').trim();
|
|
146
|
+
return text || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeSampleRow(row = {}) {
|
|
150
|
+
const normalized = {
|
|
151
|
+
age: null,
|
|
152
|
+
gender: 'unknown',
|
|
153
|
+
city: null,
|
|
154
|
+
occupation: null,
|
|
155
|
+
pain_point: null,
|
|
156
|
+
aspiration: null,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
160
|
+
const key = CSV_FIELD_ALIASES[String(rawKey ?? '').trim().toLowerCase()] ?? null;
|
|
161
|
+
if (!key) continue;
|
|
162
|
+
|
|
163
|
+
if (key === 'age') normalized.age = parseAge(rawValue);
|
|
164
|
+
else if (key === 'gender') normalized.gender = normalizeGender(rawValue);
|
|
165
|
+
else normalized[key] = normalizeToken(rawValue);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseCsv(text, maxRows) {
|
|
172
|
+
const raw = String(text ?? '').trim();
|
|
173
|
+
if (!raw) return [];
|
|
174
|
+
|
|
175
|
+
const lines = raw
|
|
176
|
+
.split(/\r?\n/)
|
|
177
|
+
.map((line) => line.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
if (lines.length < 2) return [];
|
|
180
|
+
|
|
181
|
+
const headers = lines[0].split(',').map((item) => item.trim());
|
|
182
|
+
const rows = [];
|
|
183
|
+
|
|
184
|
+
for (const line of lines.slice(1)) {
|
|
185
|
+
if (rows.length >= maxRows) break;
|
|
186
|
+
const cols = line.split(',').map((item) => item.trim());
|
|
187
|
+
const row = {};
|
|
188
|
+
for (let i = 0; i < headers.length; i += 1) {
|
|
189
|
+
row[headers[i]] = cols[i] ?? '';
|
|
190
|
+
}
|
|
191
|
+
rows.push(normalizeSampleRow(row));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return rows;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function generateSyntheticSamples({ accountId, platform, count }) {
|
|
198
|
+
const benchmark = PLATFORM_BENCHMARKS[platform] ?? PLATFORM_BENCHMARKS.xhs;
|
|
199
|
+
const pools = benchmark.pools;
|
|
200
|
+
|
|
201
|
+
const samples = [];
|
|
202
|
+
for (let i = 0; i < count; i += 1) {
|
|
203
|
+
const ageNoise = deterministicRatio(accountId, `age:${i}`) * 14 - 7;
|
|
204
|
+
const age = Math.max(16, Math.min(55, Math.round(pools.age_peak + ageNoise)));
|
|
205
|
+
const isFemale = deterministicRatio(accountId, `gender:${i}`) < pools.female_ratio;
|
|
206
|
+
|
|
207
|
+
samples.push({
|
|
208
|
+
age,
|
|
209
|
+
gender: isFemale ? 'female' : 'male',
|
|
210
|
+
city: pools.cities[Math.floor(deterministicRatio(accountId, `city:${i}`) * pools.cities.length)],
|
|
211
|
+
occupation: pools.occupations[Math.floor(deterministicRatio(accountId, `job:${i}`) * pools.occupations.length)],
|
|
212
|
+
pain_point: pools.pain_points[Math.floor(deterministicRatio(accountId, `pain:${i}`) * pools.pain_points.length)],
|
|
213
|
+
aspiration: pools.aspirations[Math.floor(deterministicRatio(accountId, `asp:${i}`) * pools.aspirations.length)],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return samples;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ageBucket(age) {
|
|
221
|
+
if (!Number.isFinite(age)) return null;
|
|
222
|
+
if (age <= 24) return '18-24';
|
|
223
|
+
if (age <= 34) return '25-34';
|
|
224
|
+
if (age <= 44) return '35-44';
|
|
225
|
+
return '45+';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function topItems(samples, key, limit = 5) {
|
|
229
|
+
const counter = new Map();
|
|
230
|
+
for (const sample of samples) {
|
|
231
|
+
const value = normalizeToken(sample?.[key]);
|
|
232
|
+
if (!value) continue;
|
|
233
|
+
counter.set(value, (counter.get(value) ?? 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [...counter.entries()]
|
|
237
|
+
.sort((a, b) => {
|
|
238
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
239
|
+
return a[0].localeCompare(b[0], 'zh-Hans-CN');
|
|
240
|
+
})
|
|
241
|
+
.slice(0, limit)
|
|
242
|
+
.map((item) => item[0]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function mergeDistinct(primary, fallback, limit = 5) {
|
|
246
|
+
const output = [];
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
|
|
249
|
+
for (const value of [...primary, ...fallback]) {
|
|
250
|
+
const text = String(value ?? '').trim();
|
|
251
|
+
if (!text || seen.has(text)) continue;
|
|
252
|
+
seen.add(text);
|
|
253
|
+
output.push(text);
|
|
254
|
+
if (output.length >= limit) break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return output;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function inferAudience({ samples, benchmark }) {
|
|
261
|
+
const ageCounter = new Map();
|
|
262
|
+
let female = 0;
|
|
263
|
+
let male = 0;
|
|
264
|
+
|
|
265
|
+
for (const sample of samples) {
|
|
266
|
+
const bucket = ageBucket(sample.age);
|
|
267
|
+
if (bucket) ageCounter.set(bucket, (ageCounter.get(bucket) ?? 0) + 1);
|
|
268
|
+
|
|
269
|
+
if (sample.gender === 'female') female += 1;
|
|
270
|
+
else if (sample.gender === 'male') male += 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const topAgeBucket = [...ageCounter.entries()]
|
|
274
|
+
.sort((a, b) => {
|
|
275
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
276
|
+
return a[0].localeCompare(b[0]);
|
|
277
|
+
})[0]?.[0] ?? null;
|
|
278
|
+
|
|
279
|
+
const genderSkew = (() => {
|
|
280
|
+
const total = female + male;
|
|
281
|
+
if (total < 6) return benchmark.default_audience.gender_skew;
|
|
282
|
+
const femaleRatio = female / total;
|
|
283
|
+
const maleRatio = male / total;
|
|
284
|
+
if (femaleRatio >= 0.58) return '\u5973\u6027\u4e3b\u5bfc';
|
|
285
|
+
if (maleRatio >= 0.58) return '\u7537\u6027\u4e3b\u5bfc';
|
|
286
|
+
return '\u5747\u8861';
|
|
287
|
+
})();
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
age_range: topAgeBucket ?? benchmark.default_audience.age_range,
|
|
291
|
+
gender_skew: genderSkew,
|
|
292
|
+
geo_focus: mergeDistinct(topItems(samples, 'city', 5), benchmark.default_audience.geo_focus, 5),
|
|
293
|
+
occupation_tags: mergeDistinct(topItems(samples, 'occupation', 5), benchmark.default_audience.occupation_tags, 5),
|
|
294
|
+
pain_points: mergeDistinct(topItems(samples, 'pain_point', 5), benchmark.default_audience.pain_points, 5),
|
|
295
|
+
aspirations: mergeDistinct(topItems(samples, 'aspiration', 5), benchmark.default_audience.aspirations, 5),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await startFixtureServer({
|
|
300
|
+
serverId: 'audience-research',
|
|
301
|
+
serverName: 'official-audience-research',
|
|
302
|
+
toolName: 'audience_research',
|
|
303
|
+
toolDescription: 'Build audience persona JSON from fixture fan snapshots plus optional uploaded CSV rows.',
|
|
304
|
+
inputSchema: {
|
|
305
|
+
account_id: z.string().min(1).describe('Target account identifier.'),
|
|
306
|
+
platform: z.enum(['xhs', 'douyin', 'wechat-mp']).optional()
|
|
307
|
+
.describe('Platform hint; inferred from account_id when omitted.'),
|
|
308
|
+
locale: z.string().optional().describe('Locale hint, default zh-CN.'),
|
|
309
|
+
follower_snapshot_csv: z.string().optional()
|
|
310
|
+
.describe('Optional CSV content. Headers supported: age,gender,city,occupation,pain_point,aspiration.'),
|
|
311
|
+
fan_samples: z.array(z.object({
|
|
312
|
+
age: z.union([z.number(), z.string()]).optional(),
|
|
313
|
+
gender: z.string().optional(),
|
|
314
|
+
city: z.string().optional(),
|
|
315
|
+
occupation: z.string().optional(),
|
|
316
|
+
pain_point: z.string().optional(),
|
|
317
|
+
aspiration: z.string().optional(),
|
|
318
|
+
})).optional().describe('Optional structured samples as an alternative to CSV upload.'),
|
|
319
|
+
sample_limit: z.number().int().min(20).max(400).optional()
|
|
320
|
+
.describe('Synthetic fan sample size for fixture estimation, default 90.'),
|
|
321
|
+
},
|
|
322
|
+
handler: ({
|
|
323
|
+
account_id,
|
|
324
|
+
platform,
|
|
325
|
+
locale = 'zh-CN',
|
|
326
|
+
follower_snapshot_csv,
|
|
327
|
+
fan_samples = [],
|
|
328
|
+
sample_limit = 90,
|
|
329
|
+
}) => {
|
|
330
|
+
const accountId = String(account_id ?? '').trim();
|
|
331
|
+
if (!accountId) throw new Error('account_id_required');
|
|
332
|
+
|
|
333
|
+
const normalizedPlatform = normalizePlatform(platform, accountId);
|
|
334
|
+
if (!Object.hasOwn(PLATFORM_BENCHMARKS, normalizedPlatform)) {
|
|
335
|
+
throw new Error(`unsupported_platform:${normalizedPlatform}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const benchmark = PLATFORM_BENCHMARKS[normalizedPlatform];
|
|
339
|
+
const normalizedLocale = String(locale ?? 'zh-CN').trim() || 'zh-CN';
|
|
340
|
+
const safeSampleLimit = clampInt(sample_limit, 20, 400, 90);
|
|
341
|
+
|
|
342
|
+
const csvSamples = parseCsv(follower_snapshot_csv, 300);
|
|
343
|
+
const directSamples = Array.isArray(fan_samples)
|
|
344
|
+
? fan_samples.slice(0, 300).map((row) => normalizeSampleRow(row))
|
|
345
|
+
: [];
|
|
346
|
+
|
|
347
|
+
const syntheticSamples = generateSyntheticSamples({
|
|
348
|
+
accountId,
|
|
349
|
+
platform: normalizedPlatform,
|
|
350
|
+
count: safeSampleLimit,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const combinedSamples = [...syntheticSamples, ...directSamples, ...csvSamples];
|
|
354
|
+
const audience = inferAudience({
|
|
355
|
+
samples: combinedSamples,
|
|
356
|
+
benchmark,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
...FIXTURE_META,
|
|
361
|
+
account_id: accountId,
|
|
362
|
+
platform: normalizedPlatform,
|
|
363
|
+
locale: normalizedLocale,
|
|
364
|
+
audience,
|
|
365
|
+
goal_schema_alignment: {
|
|
366
|
+
required_fields: ['age_range', 'gender_skew', 'geo_focus', 'occupation_tags', 'pain_points', 'aspirations'],
|
|
367
|
+
matched: true,
|
|
368
|
+
},
|
|
369
|
+
source_mix: {
|
|
370
|
+
synthetic_fan_samples: syntheticSamples.length,
|
|
371
|
+
direct_fan_samples: directSamples.length,
|
|
372
|
+
csv_fan_samples: csvSamples.length,
|
|
373
|
+
public_benchmark_platform: normalizedPlatform,
|
|
374
|
+
},
|
|
375
|
+
notes: {
|
|
376
|
+
mvp_mode: 'semi_automatic_supported',
|
|
377
|
+
csv_supported: true,
|
|
378
|
+
confidence: (directSamples.length + csvSamples.length) >= 20 ? 'medium' : 'medium_low',
|
|
379
|
+
},
|
|
380
|
+
generated_at: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "audience-research",
|
|
3
|
+
"name": "Official Audience Research MCP",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "audience_research", "classification": "cacheable" }
|
|
9
|
+
],
|
|
10
|
+
"smoke_test": {
|
|
11
|
+
"tool": "audience_research",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"account_id": "acct-xhs-demo-01",
|
|
14
|
+
"platform": "xhs",
|
|
15
|
+
"sample_limit": 60
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|