@lightcone-ai/daemon 0.15.53 → 0.15.54
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/_thin-proxy/forward.js +80 -0
- package/mcp-servers/official/audience-research/index.js +24 -376
- package/mcp-servers/official/hook-pattern-library/index.js +17 -410
- package/mcp-servers/official/keyword-research/index.js +17 -324
- package/mcp-servers/official/page-understanding/index.js +17 -96
- package/mcp-servers/official/platform-policy-db/index.js +19 -264
- package/mcp-servers/official/video-narration-planner/index.js +30 -130
- package/package.json +1 -1
- package/mcp-servers/official/keyword-research/keyword-fixtures.json +0 -58
- package/mcp-servers/official/platform-policy-db/policy-fixtures.json +0 -257
- package/mcp-servers/official/video-narration-planner/core.js +0 -1403
- package/mcp-servers/official/video-narration-planner/planner-config.json +0 -112
- package/src/_vendor/video/understanding/analyze-page.js +0 -737
- package/src/_vendor/video/understanding/heuristics.js +0 -826
- package/src/_vendor/video/understanding/index.js +0 -11
- package/src/_vendor/video/understanding/llm-client.js +0 -261
- package/src/_vendor/video/understanding/schema.js +0 -254
- package/src/_vendor/video/understanding/site-selectors.js +0 -47
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Generic thin-proxy helper for MCP servers whose implementation lives on
|
|
2
|
+
// the lightcone server (D17 anti-leak). Each daemon-side MCP server using
|
|
3
|
+
// this helper just declares its tool list and a server_id; per-tool calls
|
|
4
|
+
// are forwarded to /internal/agent/:agentId/mcp/:serverId/:toolName.
|
|
5
|
+
//
|
|
6
|
+
// Usage (in daemon/mcp-servers/<some-name>/index.js):
|
|
7
|
+
//
|
|
8
|
+
// import { startThinProxy } from '../../_thin-proxy/forward.js';
|
|
9
|
+
// await startThinProxy({
|
|
10
|
+
// serverId: 'platform-policy-db',
|
|
11
|
+
// serverName: 'official-platform-policy-db',
|
|
12
|
+
// tools: [
|
|
13
|
+
// { name: 'platform_policy_db', description: '...', inputSchema: { ... } },
|
|
14
|
+
// ],
|
|
15
|
+
// });
|
|
16
|
+
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
const SERVER_URL = process.env.SERVER_URL ?? '';
|
|
22
|
+
const MACHINE_API_KEY = process.env.MACHINE_API_KEY ?? '';
|
|
23
|
+
const AGENT_ID = process.env.AGENT_ID ?? '';
|
|
24
|
+
|
|
25
|
+
function toTextContent(payload) {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
29
|
+
} catch {
|
|
30
|
+
text = String(payload);
|
|
31
|
+
}
|
|
32
|
+
return { content: [{ type: 'text', text }] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function forwardToServer(serverId, toolName, args) {
|
|
36
|
+
if (!SERVER_URL || !MACHINE_API_KEY || !AGENT_ID) {
|
|
37
|
+
throw new Error('thin-proxy missing SERVER_URL / MACHINE_API_KEY / AGENT_ID env');
|
|
38
|
+
}
|
|
39
|
+
const url = `${SERVER_URL}/internal/agent/${encodeURIComponent(AGENT_ID)}/mcp/${encodeURIComponent(serverId)}/${encodeURIComponent(toolName)}`;
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
'Authorization': `Bearer ${MACHINE_API_KEY}`,
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify(args ?? {}),
|
|
47
|
+
});
|
|
48
|
+
let body = null;
|
|
49
|
+
try { body = await res.json(); } catch { /* ignore */ }
|
|
50
|
+
if (!res.ok || !body?.ok) {
|
|
51
|
+
const reason = body?.error ?? `HTTP ${res.status}`;
|
|
52
|
+
throw new Error(reason);
|
|
53
|
+
}
|
|
54
|
+
return body.result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function startThinProxy({ serverId, serverName, version = '1.0.0', tools }) {
|
|
58
|
+
const server = new McpServer({ name: serverName ?? serverId, version });
|
|
59
|
+
for (const tool of tools ?? []) {
|
|
60
|
+
if (!tool?.name) continue;
|
|
61
|
+
const description = tool.description ?? '';
|
|
62
|
+
// We intentionally accept ANY input here — the server-side handler
|
|
63
|
+
// performs full validation, so the proxy stays thin and free of
|
|
64
|
+
// duplicated schema definitions.
|
|
65
|
+
const inputSchema = tool.inputSchema ?? { args: z.any().optional() };
|
|
66
|
+
server.tool(tool.name, description, inputSchema, async (args) => {
|
|
67
|
+
try {
|
|
68
|
+
const result = await forwardToServer(serverId, tool.name, args ?? {});
|
|
69
|
+
return toTextContent(result);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
isError: true,
|
|
73
|
+
content: [{ type: 'text', text: `Error: ${err?.message ?? String(err)}` }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
await server.connect(new StdioServerTransport());
|
|
79
|
+
console.error(`[${serverId}] thin-proxy started → ${SERVER_URL || '<no-server-url>'}`);
|
|
80
|
+
}
|
|
@@ -1,383 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Thin-proxy. Real impl on lightcone server.
|
|
3
|
+
// Source of truth: src/mcp-services/audience-research.js
|
|
2
4
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
5
|
+
import { startThinProxy } from '../../_thin-proxy/forward.js';
|
|
4
6
|
|
|
5
|
-
|
|
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({
|
|
7
|
+
await startThinProxy({
|
|
300
8
|
serverId: 'audience-research',
|
|
301
9
|
serverName: 'official-audience-research',
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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,
|
|
10
|
+
tools: [
|
|
11
|
+
{
|
|
12
|
+
name: 'audience_research',
|
|
13
|
+
description: 'Build audience persona JSON from fixture fan snapshots plus optional uploaded CSV rows.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
account_id: z.string().min(1),
|
|
16
|
+
platform: z.enum(['xhs', 'douyin', 'wechat-mp']).optional(),
|
|
17
|
+
locale: z.string().optional(),
|
|
18
|
+
follower_snapshot_csv: z.string().optional(),
|
|
19
|
+
fan_samples: z.array(z.object({
|
|
20
|
+
age: z.union([z.number(), z.string()]).optional(),
|
|
21
|
+
gender: z.string().optional(),
|
|
22
|
+
city: z.string().optional(),
|
|
23
|
+
occupation: z.string().optional(),
|
|
24
|
+
pain_point: z.string().optional(),
|
|
25
|
+
aspiration: z.string().optional(),
|
|
26
|
+
})).optional(),
|
|
27
|
+
sample_limit: z.number().int().min(20).max(400).optional(),
|
|
368
28
|
},
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
383
31
|
});
|