@lightcone-ai/daemon 0.11.0 → 0.12.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
|
@@ -0,0 +1,316 @@
|
|
|
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: 'platform-policy-db',
|
|
9
|
+
disclaimer: 'Policy fixtures are static snapshots; always verify against latest platform notices.',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PLATFORM_POLICY_DATA = Object.freeze({
|
|
13
|
+
xhs: Object.freeze({
|
|
14
|
+
platform: 'xhs',
|
|
15
|
+
display_name: '小红书',
|
|
16
|
+
policy_version: 'xhs-2026.04',
|
|
17
|
+
updated_at: '2026-04-22',
|
|
18
|
+
ai_label_required: true,
|
|
19
|
+
ad_disclosure_required: true,
|
|
20
|
+
policy_window_days: 7,
|
|
21
|
+
required_labels: {
|
|
22
|
+
ad: '赞助',
|
|
23
|
+
ai: 'AI 生成内容标识',
|
|
24
|
+
},
|
|
25
|
+
sensitive_terms: Object.freeze([
|
|
26
|
+
{
|
|
27
|
+
id: 'xhs-external-redirect-wechat',
|
|
28
|
+
term: '微信',
|
|
29
|
+
severity: 'blocker',
|
|
30
|
+
reason: '站外导流词,易触发限流或违规。',
|
|
31
|
+
action: 'remove_redirect_hint',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'xhs-external-redirect-qr',
|
|
35
|
+
term: '二维码',
|
|
36
|
+
severity: 'blocker',
|
|
37
|
+
reason: '站外导流相关词,发布前需清理。',
|
|
38
|
+
action: 'remove_redirect_hint',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'xhs-fake-luxury',
|
|
42
|
+
term: '高仿',
|
|
43
|
+
severity: 'blocker',
|
|
44
|
+
reason: '涉嫌仿冒交易,政策高风险。',
|
|
45
|
+
action: 'reject_publish',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'xhs-medical-guarantee',
|
|
49
|
+
term: '疗效保证',
|
|
50
|
+
severity: 'warn',
|
|
51
|
+
reason: '医疗功效承诺需附免责声明并谨慎表述。',
|
|
52
|
+
action: 'add_disclaimer',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'xhs-finance-guarantee',
|
|
56
|
+
term: '稳赚不赔',
|
|
57
|
+
severity: 'blocker',
|
|
58
|
+
reason: '金融收益承诺违规风险高。',
|
|
59
|
+
action: 'remove_claim',
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
}),
|
|
63
|
+
douyin: Object.freeze({
|
|
64
|
+
platform: 'douyin',
|
|
65
|
+
display_name: '抖音',
|
|
66
|
+
policy_version: 'douyin-2026.04',
|
|
67
|
+
updated_at: '2026-04-20',
|
|
68
|
+
ai_label_required: true,
|
|
69
|
+
ad_disclosure_required: true,
|
|
70
|
+
policy_window_days: 7,
|
|
71
|
+
required_labels: {
|
|
72
|
+
ad: '广告',
|
|
73
|
+
ai: 'AI 生成声明',
|
|
74
|
+
},
|
|
75
|
+
sensitive_terms: Object.freeze([
|
|
76
|
+
{
|
|
77
|
+
id: 'douyin-fake-news',
|
|
78
|
+
term: '内幕消息',
|
|
79
|
+
severity: 'blocker',
|
|
80
|
+
reason: '未经证实信息易触发平台处罚。',
|
|
81
|
+
action: 'remove_unverified_claim',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'douyin-disaster-clickbait',
|
|
85
|
+
term: '震惊全国',
|
|
86
|
+
severity: 'warn',
|
|
87
|
+
reason: '标题党表达可能降低推荐稳定性。',
|
|
88
|
+
action: 'tone_down_title',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'douyin-finance-guarantee',
|
|
92
|
+
term: '保本保收益',
|
|
93
|
+
severity: 'blocker',
|
|
94
|
+
reason: '金融收益保障承诺违规。',
|
|
95
|
+
action: 'remove_claim',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'douyin-medical-cure',
|
|
99
|
+
term: '包治百病',
|
|
100
|
+
severity: 'blocker',
|
|
101
|
+
reason: '医疗夸大宣传高风险。',
|
|
102
|
+
action: 'remove_claim',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'douyin-traffic-manipulation',
|
|
106
|
+
term: '互赞互粉',
|
|
107
|
+
severity: 'warn',
|
|
108
|
+
reason: '可被识别为异常增长操作。',
|
|
109
|
+
action: 'remove_growth_hack',
|
|
110
|
+
},
|
|
111
|
+
]),
|
|
112
|
+
}),
|
|
113
|
+
'wechat-mp': Object.freeze({
|
|
114
|
+
platform: 'wechat-mp',
|
|
115
|
+
display_name: '公众号',
|
|
116
|
+
policy_version: 'wechat-mp-2026.04',
|
|
117
|
+
updated_at: '2026-04-19',
|
|
118
|
+
ai_label_required: false,
|
|
119
|
+
ad_disclosure_required: true,
|
|
120
|
+
policy_window_days: 7,
|
|
121
|
+
required_labels: {
|
|
122
|
+
ad: '广告/合作声明',
|
|
123
|
+
ai: '建议标注 AI 生成来源',
|
|
124
|
+
},
|
|
125
|
+
sensitive_terms: Object.freeze([
|
|
126
|
+
{
|
|
127
|
+
id: 'wechatmp-clickbait-share',
|
|
128
|
+
term: '不转不是中国人',
|
|
129
|
+
severity: 'blocker',
|
|
130
|
+
reason: '诱导分享属于明确违规。',
|
|
131
|
+
action: 'remove_manipulative_phrase',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'wechatmp-political-rumor',
|
|
135
|
+
term: '内部通知',
|
|
136
|
+
severity: 'warn',
|
|
137
|
+
reason: '疑似无来源权威口径,需补证据。',
|
|
138
|
+
action: 'add_citation_or_remove',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'wechatmp-medical-guarantee',
|
|
142
|
+
term: '根治',
|
|
143
|
+
severity: 'warn',
|
|
144
|
+
reason: '医疗强承诺需免责声明并谨慎表达。',
|
|
145
|
+
action: 'add_disclaimer',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'wechatmp-finance-guarantee',
|
|
149
|
+
term: '稳赚',
|
|
150
|
+
severity: 'blocker',
|
|
151
|
+
reason: '金融收益承诺违规。',
|
|
152
|
+
action: 'remove_claim',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'wechatmp-fake-scarcity',
|
|
156
|
+
term: '最后一天',
|
|
157
|
+
severity: 'warn',
|
|
158
|
+
reason: '虚假稀缺文案易触发风控。',
|
|
159
|
+
action: 'verify_or_tone_down',
|
|
160
|
+
},
|
|
161
|
+
]),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function normalizePlatform(value) {
|
|
166
|
+
const raw = String(value ?? '').trim().toLowerCase();
|
|
167
|
+
if (!raw) return '';
|
|
168
|
+
if (raw === 'xiaohongshu' || raw === 'redbook') return 'xhs';
|
|
169
|
+
if (raw === 'wechat' || raw === 'wechatmp' || raw === 'wechat_mp' || raw === 'gzh' || raw === '公众号') {
|
|
170
|
+
return 'wechat-mp';
|
|
171
|
+
}
|
|
172
|
+
if (raw === 'tiktok_cn') return 'douyin';
|
|
173
|
+
return raw;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findSnippet(text, term) {
|
|
177
|
+
const source = String(text ?? '');
|
|
178
|
+
const target = String(term ?? '');
|
|
179
|
+
if (!source || !target) return '';
|
|
180
|
+
|
|
181
|
+
const index = source.toLowerCase().indexOf(target.toLowerCase());
|
|
182
|
+
if (index < 0) return '';
|
|
183
|
+
|
|
184
|
+
const start = Math.max(0, index - 10);
|
|
185
|
+
const end = Math.min(source.length, index + target.length + 10);
|
|
186
|
+
return source.slice(start, end);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function listPlatformsSummary() {
|
|
190
|
+
return Object.values(PLATFORM_POLICY_DATA).map((entry) => ({
|
|
191
|
+
platform: entry.platform,
|
|
192
|
+
display_name: entry.display_name,
|
|
193
|
+
policy_version: entry.policy_version,
|
|
194
|
+
updated_at: entry.updated_at,
|
|
195
|
+
ai_label_required: entry.ai_label_required,
|
|
196
|
+
ad_disclosure_required: entry.ad_disclosure_required,
|
|
197
|
+
sensitive_term_count: entry.sensitive_terms.length,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await startFixtureServer({
|
|
202
|
+
serverId: 'platform-policy-db',
|
|
203
|
+
serverName: 'official-platform-policy-db',
|
|
204
|
+
toolName: 'platform_policy_db',
|
|
205
|
+
toolDescription: 'Return policy fixtures and sensitive-term scan results for publishing precheck.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
operation: z.enum(['list_platforms', 'get_rules', 'scan_text']).optional()
|
|
208
|
+
.describe('Operation: list platforms, get platform rules, or scan draft text.'),
|
|
209
|
+
platform: z.string().optional()
|
|
210
|
+
.describe('Target platform id or alias, e.g. xhs, douyin, wechat-mp.'),
|
|
211
|
+
text: z.string().optional()
|
|
212
|
+
.describe('Draft text for sensitive-term scan when operation=scan_text.'),
|
|
213
|
+
include_terms: z.boolean().optional()
|
|
214
|
+
.describe('Whether full term list is included in get_rules result (default true).'),
|
|
215
|
+
max_matches: z.number().int().min(1).max(100).optional()
|
|
216
|
+
.describe('Max returned violations for scan_text (default 20).'),
|
|
217
|
+
policy_version: z.string().optional()
|
|
218
|
+
.describe('Optional expected policy version; mismatch is returned as warning.'),
|
|
219
|
+
},
|
|
220
|
+
handler: ({
|
|
221
|
+
operation = 'get_rules',
|
|
222
|
+
platform,
|
|
223
|
+
text,
|
|
224
|
+
include_terms = true,
|
|
225
|
+
max_matches = 20,
|
|
226
|
+
policy_version,
|
|
227
|
+
}) => {
|
|
228
|
+
if (operation === 'list_platforms') {
|
|
229
|
+
return {
|
|
230
|
+
...FIXTURE_META,
|
|
231
|
+
operation,
|
|
232
|
+
count: Object.keys(PLATFORM_POLICY_DATA).length,
|
|
233
|
+
platforms: listPlatformsSummary(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
238
|
+
if (!normalizedPlatform || !Object.hasOwn(PLATFORM_POLICY_DATA, normalizedPlatform)) {
|
|
239
|
+
throw new Error(`unsupported_platform:${platform}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const policy = PLATFORM_POLICY_DATA[normalizedPlatform];
|
|
243
|
+
const versionMismatch = policy_version && policy_version !== policy.policy_version;
|
|
244
|
+
|
|
245
|
+
if (operation === 'get_rules') {
|
|
246
|
+
return {
|
|
247
|
+
...FIXTURE_META,
|
|
248
|
+
operation,
|
|
249
|
+
platform: policy.platform,
|
|
250
|
+
display_name: policy.display_name,
|
|
251
|
+
policy_version: policy.policy_version,
|
|
252
|
+
updated_at: policy.updated_at,
|
|
253
|
+
ai_label_required: policy.ai_label_required,
|
|
254
|
+
ad_disclosure_required: policy.ad_disclosure_required,
|
|
255
|
+
policy_window_days: policy.policy_window_days,
|
|
256
|
+
required_labels: policy.required_labels,
|
|
257
|
+
sensitive_term_count: policy.sensitive_terms.length,
|
|
258
|
+
sensitive_terms: include_terms ? policy.sensitive_terms : undefined,
|
|
259
|
+
warnings: versionMismatch ? [{
|
|
260
|
+
code: 'policy_version_mismatch',
|
|
261
|
+
expected: policy.policy_version,
|
|
262
|
+
received: policy_version,
|
|
263
|
+
}] : [],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const draftText = String(text ?? '').trim();
|
|
268
|
+
if (!draftText) {
|
|
269
|
+
throw new Error('scan_text_requires_text');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const limitedMaxMatches = Math.max(1, Math.min(100, Math.trunc(Number(max_matches) || 20)));
|
|
273
|
+
const lowerText = draftText.toLowerCase();
|
|
274
|
+
|
|
275
|
+
const violations = [];
|
|
276
|
+
for (const rule of policy.sensitive_terms) {
|
|
277
|
+
if (!lowerText.includes(rule.term.toLowerCase())) continue;
|
|
278
|
+
violations.push({
|
|
279
|
+
rule_id: rule.id,
|
|
280
|
+
term: rule.term,
|
|
281
|
+
severity: rule.severity,
|
|
282
|
+
reason: rule.reason,
|
|
283
|
+
action: rule.action,
|
|
284
|
+
snippet: findSnippet(draftText, rule.term),
|
|
285
|
+
});
|
|
286
|
+
if (violations.length >= limitedMaxMatches) break;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const severityStats = violations.reduce((acc, item) => {
|
|
290
|
+
acc[item.severity] = (acc[item.severity] ?? 0) + 1;
|
|
291
|
+
return acc;
|
|
292
|
+
}, {});
|
|
293
|
+
const blockerCount = severityStats.blocker ?? 0;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
...FIXTURE_META,
|
|
297
|
+
operation,
|
|
298
|
+
platform: policy.platform,
|
|
299
|
+
policy_version: policy.policy_version,
|
|
300
|
+
scanned_length: draftText.length,
|
|
301
|
+
match_count: violations.length,
|
|
302
|
+
blocker_count: blockerCount,
|
|
303
|
+
has_blocker: blockerCount > 0,
|
|
304
|
+
severity_stats: severityStats,
|
|
305
|
+
violations,
|
|
306
|
+
warnings: versionMismatch ? [{
|
|
307
|
+
code: 'policy_version_mismatch',
|
|
308
|
+
expected: policy.policy_version,
|
|
309
|
+
received: policy_version,
|
|
310
|
+
}] : [],
|
|
311
|
+
required_labels: policy.required_labels,
|
|
312
|
+
ai_label_required: policy.ai_label_required,
|
|
313
|
+
ad_disclosure_required: policy.ad_disclosure_required,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "platform-policy-db",
|
|
3
|
+
"name": "Official Platform Policy DB MCP",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "platform_policy_db", "classification": "mandatory" }
|
|
9
|
+
],
|
|
10
|
+
"smoke_test": {
|
|
11
|
+
"tool": "platform_policy_db",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"operation": "scan_text",
|
|
14
|
+
"platform": "xhs",
|
|
15
|
+
"text": "本内容仅分享经验,不含微信或二维码导流。"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
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: 'publish-window-optimizer',
|
|
9
|
+
disclaimer: 'Recommendations are heuristic and should be validated against real account telemetry.',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const BASELINE_HOUR_SCORES = Object.freeze({
|
|
13
|
+
xhs: Object.freeze({
|
|
14
|
+
7: 45, 8: 62, 11: 66, 12: 72, 13: 60, 18: 76, 19: 85, 20: 91, 21: 87, 22: 74,
|
|
15
|
+
}),
|
|
16
|
+
douyin: Object.freeze({
|
|
17
|
+
8: 54, 12: 64, 13: 58, 17: 74, 18: 82, 19: 91, 20: 95, 21: 88, 22: 80, 23: 70,
|
|
18
|
+
}),
|
|
19
|
+
'wechat-mp': Object.freeze({
|
|
20
|
+
7: 68, 8: 84, 9: 76, 12: 79, 18: 58, 20: 73, 21: 80, 22: 71,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function normalizePlatform(value) {
|
|
25
|
+
const raw = String(value ?? '').trim().toLowerCase();
|
|
26
|
+
if (!raw) return 'xhs';
|
|
27
|
+
if (raw === 'xiaohongshu' || raw === 'redbook') return 'xhs';
|
|
28
|
+
if (raw === 'wechat' || raw === 'wechat_mp' || raw === 'wechatmp' || raw === 'gzh' || raw === '公众号') {
|
|
29
|
+
return 'wechat-mp';
|
|
30
|
+
}
|
|
31
|
+
if (raw === 'tiktok_cn') return 'douyin';
|
|
32
|
+
if (!Object.hasOwn(BASELINE_HOUR_SCORES, raw)) return 'xhs';
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeTimeZone(timeZone) {
|
|
37
|
+
const zone = String(timeZone ?? '').trim();
|
|
38
|
+
if (!zone) return 'Asia/Shanghai';
|
|
39
|
+
try {
|
|
40
|
+
new Intl.DateTimeFormat('en-US', { timeZone: zone }).format(new Date());
|
|
41
|
+
return zone;
|
|
42
|
+
} catch {
|
|
43
|
+
return 'Asia/Shanghai';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractHour(dateLike, timeZone) {
|
|
48
|
+
const date = new Date(dateLike);
|
|
49
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
50
|
+
const hourText = new Intl.DateTimeFormat('en-US', {
|
|
51
|
+
hour: '2-digit',
|
|
52
|
+
hour12: false,
|
|
53
|
+
timeZone,
|
|
54
|
+
}).format(date);
|
|
55
|
+
const hour = Number(hourText);
|
|
56
|
+
return Number.isInteger(hour) ? hour : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clampInt(value, min, max, fallback) {
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
if (!Number.isFinite(n)) return fallback;
|
|
62
|
+
return Math.max(min, Math.min(max, Math.trunc(n)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toFiniteNumber(value) {
|
|
66
|
+
const n = Number(value);
|
|
67
|
+
return Number.isFinite(n) ? n : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function metricScore(sample, primaryMetric) {
|
|
71
|
+
const metric = String(primaryMetric ?? '').trim().toLowerCase();
|
|
72
|
+
|
|
73
|
+
if (metric === 'engagement_rate') {
|
|
74
|
+
const v = toFiniteNumber(sample.engagement_rate);
|
|
75
|
+
return v == null ? null : v * 100;
|
|
76
|
+
}
|
|
77
|
+
if (metric === 'conversion_rate') {
|
|
78
|
+
const v = toFiniteNumber(sample.conversion_rate);
|
|
79
|
+
return v == null ? null : v * 120;
|
|
80
|
+
}
|
|
81
|
+
if (metric === 'views') {
|
|
82
|
+
const v = toFiniteNumber(sample.views);
|
|
83
|
+
return v == null ? null : v / 120;
|
|
84
|
+
}
|
|
85
|
+
if (metric === 'clicks') {
|
|
86
|
+
const v = toFiniteNumber(sample.clicks);
|
|
87
|
+
return v == null ? null : v * 1.2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const engagementRate = toFiniteNumber(sample.engagement_rate);
|
|
91
|
+
const conversionRate = toFiniteNumber(sample.conversion_rate);
|
|
92
|
+
const views = toFiniteNumber(sample.views);
|
|
93
|
+
const clicks = toFiniteNumber(sample.clicks);
|
|
94
|
+
|
|
95
|
+
let score = 0;
|
|
96
|
+
let used = 0;
|
|
97
|
+
if (engagementRate != null) {
|
|
98
|
+
score += engagementRate * 80;
|
|
99
|
+
used += 1;
|
|
100
|
+
}
|
|
101
|
+
if (conversionRate != null) {
|
|
102
|
+
score += conversionRate * 120;
|
|
103
|
+
used += 1;
|
|
104
|
+
}
|
|
105
|
+
if (views != null) {
|
|
106
|
+
score += views / 150;
|
|
107
|
+
used += 1;
|
|
108
|
+
}
|
|
109
|
+
if (clicks != null) {
|
|
110
|
+
score += clicks;
|
|
111
|
+
used += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (used === 0) return null;
|
|
115
|
+
return score / used;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildHourScoring({
|
|
119
|
+
platform,
|
|
120
|
+
history,
|
|
121
|
+
primaryMetric,
|
|
122
|
+
timeZone,
|
|
123
|
+
}) {
|
|
124
|
+
const baseline = BASELINE_HOUR_SCORES[platform] ?? {};
|
|
125
|
+
const hourState = new Map();
|
|
126
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
127
|
+
hourState.set(hour, {
|
|
128
|
+
hour,
|
|
129
|
+
baseline_score: Number(baseline[hour] ?? 35),
|
|
130
|
+
observed_sum: 0,
|
|
131
|
+
observed_count: 0,
|
|
132
|
+
observed_avg: null,
|
|
133
|
+
score: Number(baseline[hour] ?? 35),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const sample of history) {
|
|
138
|
+
const publishedAt = String(sample?.published_at ?? sample?.publishedAt ?? '').trim();
|
|
139
|
+
if (!publishedAt) continue;
|
|
140
|
+
const hour = extractHour(publishedAt, timeZone);
|
|
141
|
+
if (hour == null) continue;
|
|
142
|
+
const sampleScore = metricScore(sample, primaryMetric);
|
|
143
|
+
if (sampleScore == null) continue;
|
|
144
|
+
|
|
145
|
+
const entry = hourState.get(hour);
|
|
146
|
+
if (!entry) continue;
|
|
147
|
+
entry.observed_sum += sampleScore;
|
|
148
|
+
entry.observed_count += 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const entry of hourState.values()) {
|
|
152
|
+
if (entry.observed_count > 0) {
|
|
153
|
+
entry.observed_avg = entry.observed_sum / entry.observed_count;
|
|
154
|
+
const blended = entry.baseline_score * 0.35 + entry.observed_avg * 0.65;
|
|
155
|
+
entry.score = Number(blended.toFixed(2));
|
|
156
|
+
} else {
|
|
157
|
+
entry.score = Number(entry.baseline_score.toFixed(2));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [...hourState.values()];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function circularHourGap(fromHour, toHour) {
|
|
165
|
+
const raw = Math.abs(fromHour - toHour);
|
|
166
|
+
return Math.min(raw, 24 - raw);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatHourRange(hour) {
|
|
170
|
+
const start = String(hour).padStart(2, '0');
|
|
171
|
+
const end = String((hour + 1) % 24).padStart(2, '0');
|
|
172
|
+
return `${start}:00-${end}:00`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await startFixtureServer({
|
|
176
|
+
serverId: 'publish-window-optimizer',
|
|
177
|
+
serverName: 'official-publish-window-optimizer',
|
|
178
|
+
toolName: 'publish_window_optimizer',
|
|
179
|
+
toolDescription: 'Suggest best publish windows by platform and account history heuristics.',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
platform: z.enum(['xhs', 'douyin', 'wechat-mp']).optional()
|
|
182
|
+
.describe('Target platform identifier.'),
|
|
183
|
+
account_id: z.string().optional().describe('Account identifier for reporting only.'),
|
|
184
|
+
timezone: z.string().optional().describe('IANA time zone, default Asia/Shanghai.'),
|
|
185
|
+
primary_metric: z.enum(['engagement_rate', 'conversion_rate', 'views', 'clicks']).optional()
|
|
186
|
+
.describe('Metric used for score aggregation; default is blended score.'),
|
|
187
|
+
history: z.array(z.object({
|
|
188
|
+
published_at: z.string(),
|
|
189
|
+
engagement_rate: z.number().optional(),
|
|
190
|
+
conversion_rate: z.number().optional(),
|
|
191
|
+
views: z.number().optional(),
|
|
192
|
+
clicks: z.number().optional(),
|
|
193
|
+
})).optional().describe('Historical publish samples for the account.'),
|
|
194
|
+
current_time: z.string().optional().describe('Override current timestamp for advisory output.'),
|
|
195
|
+
limit: z.number().int().min(1).max(6).optional().describe('Top-N windows for best/secondary lists.'),
|
|
196
|
+
},
|
|
197
|
+
handler: ({
|
|
198
|
+
platform,
|
|
199
|
+
account_id,
|
|
200
|
+
timezone,
|
|
201
|
+
primary_metric,
|
|
202
|
+
history = [],
|
|
203
|
+
current_time,
|
|
204
|
+
limit = 3,
|
|
205
|
+
}) => {
|
|
206
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
207
|
+
const normalizedTimeZone = safeTimeZone(timezone);
|
|
208
|
+
const safeLimit = clampInt(limit, 1, 6, 3);
|
|
209
|
+
const scoreRows = buildHourScoring({
|
|
210
|
+
platform: normalizedPlatform,
|
|
211
|
+
history: Array.isArray(history) ? history : [],
|
|
212
|
+
primaryMetric: primary_metric,
|
|
213
|
+
timeZone: normalizedTimeZone,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const ranked = [...scoreRows]
|
|
217
|
+
.sort((a, b) => {
|
|
218
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
219
|
+
return a.hour - b.hour;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const bestWindows = ranked.slice(0, safeLimit).map((item) => ({
|
|
223
|
+
hour: item.hour,
|
|
224
|
+
window: formatHourRange(item.hour),
|
|
225
|
+
score: item.score,
|
|
226
|
+
evidence_count: item.observed_count,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const secondaryWindows = ranked.slice(safeLimit, safeLimit * 2).map((item) => ({
|
|
230
|
+
hour: item.hour,
|
|
231
|
+
window: formatHourRange(item.hour),
|
|
232
|
+
score: item.score,
|
|
233
|
+
evidence_count: item.observed_count,
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
const avoidWindows = [...ranked]
|
|
237
|
+
.reverse()
|
|
238
|
+
.slice(0, safeLimit)
|
|
239
|
+
.map((item) => ({
|
|
240
|
+
hour: item.hour,
|
|
241
|
+
window: formatHourRange(item.hour),
|
|
242
|
+
score: item.score,
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const now = current_time ? new Date(current_time) : new Date();
|
|
246
|
+
const currentHour = Number.isNaN(now.getTime())
|
|
247
|
+
? null
|
|
248
|
+
: extractHour(now.toISOString(), normalizedTimeZone);
|
|
249
|
+
|
|
250
|
+
const topHour = bestWindows[0]?.hour ?? null;
|
|
251
|
+
const gap = (currentHour != null && topHour != null)
|
|
252
|
+
? circularHourGap(currentHour, topHour)
|
|
253
|
+
: null;
|
|
254
|
+
|
|
255
|
+
const advisory = (() => {
|
|
256
|
+
if (currentHour == null || topHour == null || gap == null) {
|
|
257
|
+
return {
|
|
258
|
+
level: 'neutral',
|
|
259
|
+
message: 'Insufficient timing context for real-time advisory.',
|
|
260
|
+
delay_recommended: false,
|
|
261
|
+
suggested_window: bestWindows[0]?.window ?? null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (gap > 2) {
|
|
265
|
+
return {
|
|
266
|
+
level: 'suggest_delay',
|
|
267
|
+
message: `Current hour is ${gap}h away from top window; consider delaying to ${formatHourRange(topHour)}.`,
|
|
268
|
+
delay_recommended: true,
|
|
269
|
+
suggested_window: formatHourRange(topHour),
|
|
270
|
+
current_hour_window: formatHourRange(currentHour),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
level: 'ok_to_publish',
|
|
275
|
+
message: `Current hour is close to recommended window (${formatHourRange(topHour)}).`,
|
|
276
|
+
delay_recommended: false,
|
|
277
|
+
suggested_window: formatHourRange(topHour),
|
|
278
|
+
current_hour_window: formatHourRange(currentHour),
|
|
279
|
+
};
|
|
280
|
+
})();
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
...FIXTURE_META,
|
|
284
|
+
platform: normalizedPlatform,
|
|
285
|
+
account_id: account_id ?? null,
|
|
286
|
+
timezone: normalizedTimeZone,
|
|
287
|
+
primary_metric: primary_metric ?? 'blended',
|
|
288
|
+
history_sample_count: Array.isArray(history) ? history.length : 0,
|
|
289
|
+
best_windows: bestWindows,
|
|
290
|
+
secondary_windows: secondaryWindows,
|
|
291
|
+
avoid_windows: avoidWindows,
|
|
292
|
+
advisory,
|
|
293
|
+
generated_at: new Date().toISOString(),
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "publish-window-optimizer",
|
|
3
|
+
"name": "Official Publish Window Optimizer MCP",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "publish_window_optimizer", "classification": "cacheable" }
|
|
9
|
+
],
|
|
10
|
+
"smoke_test": {
|
|
11
|
+
"tool": "publish_window_optimizer",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"platform": "douyin",
|
|
14
|
+
"limit": 2
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class PublisherAdapter {
|
|
2
|
+
constructor(cdp) {
|
|
3
|
+
this._cdp = cdp;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
getCapabilities() {
|
|
7
|
+
throw new Error(`${this.constructor.name} must implement getCapabilities()`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
getRequirements(_contentType) {
|
|
11
|
+
throw new Error(`${this.constructor.name} must implement getRequirements(contentType)`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async checkLoginStatus() {
|
|
15
|
+
throw new Error(`${this.constructor.name} must implement checkLoginStatus()`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async publishImageText(_input) {
|
|
19
|
+
throw new Error(`${this.constructor.name} must implement publishImageText(input)`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async publishShortVideo(_input) {
|
|
23
|
+
throw new Error(`${this.constructor.name} must implement publishShortVideo(input)`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async publishLongVideo(_input) {
|
|
27
|
+
throw new Error('PUBLISH_UNSUPPORTED_CONTENT_TYPE: long_video is not supported by this adapter');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async publishLongText(_input) {
|
|
31
|
+
throw new Error('PUBLISH_UNSUPPORTED_CONTENT_TYPE: long_text is not supported by this adapter');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async withdrawPost(_input) {
|
|
35
|
+
throw new Error('PUBLISH_UNSUPPORTED_FEATURE: withdraw_post is not supported by this adapter');
|
|
36
|
+
}
|
|
37
|
+
}
|