ppxc-leads-mcp 0.1.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.
Files changed (35) hide show
  1. package/README.md +115 -0
  2. package/dist/backend/config.js +13 -0
  3. package/dist/backend/ppxc-client.js +156 -0
  4. package/dist/backend/ppxc-login-window.js +168 -0
  5. package/dist/backend/token-store.js +65 -0
  6. package/dist/browser/comments.js +9 -0
  7. package/dist/browser/douyin-runner.js +15 -0
  8. package/dist/browser/kernel/electron-profile.js +32 -0
  9. package/dist/browser/kernel/logger.js +57 -0
  10. package/dist/browser/kernel/page-scripts/index.js +1422 -0
  11. package/dist/browser/kernel/runner-page-manager.js +145 -0
  12. package/dist/browser/kernel/runner-page-session.js +1465 -0
  13. package/dist/browser/kernel/runner-page-session.search-parser.js +187 -0
  14. package/dist/browser/kernel/runner-page-session.user-agent.js +32 -0
  15. package/dist/browser/platform-runner.js +312 -0
  16. package/dist/browser/platforms/detect-platform.js +33 -0
  17. package/dist/browser/platforms/douyin/adapter.js +162 -0
  18. package/dist/browser/platforms/douyin/comments.js +130 -0
  19. package/dist/browser/platforms/kuaishou/adapter.js +178 -0
  20. package/dist/browser/platforms/kuaishou/comments.js +170 -0
  21. package/dist/browser/platforms/registry.js +23 -0
  22. package/dist/browser/platforms/shared/cdp-json-waiter.js +75 -0
  23. package/dist/browser/platforms/types.js +3 -0
  24. package/dist/browser/platforms/xiaohongshu/adapter.js +233 -0
  25. package/dist/browser/platforms/xiaohongshu/comments.js +184 -0
  26. package/dist/browser/usage-throttle.js +72 -0
  27. package/dist/main.js +64 -0
  28. package/dist/mcp/battle-report.js +325 -0
  29. package/dist/mcp/content-insights.js +66 -0
  30. package/dist/mcp/diagnostics.js +79 -0
  31. package/dist/mcp/server.js +829 -0
  32. package/dist/version.js +19 -0
  33. package/package.json +43 -0
  34. package/scripts/launch-mcp.cjs +96 -0
  35. package/skills/ppxc-find-customers/SKILL.md +110 -0
@@ -0,0 +1,829 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMcpServer = createMcpServer;
4
+ exports.startMcpServer = startMcpServer;
5
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
6
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
7
+ const zod_1 = require("zod");
8
+ const platform_runner_1 = require("../browser/platform-runner");
9
+ const detect_platform_1 = require("../browser/platforms/detect-platform");
10
+ const registry_1 = require("../browser/platforms/registry");
11
+ const logger_1 = require("../browser/kernel/logger");
12
+ const ppxc_login_window_1 = require("../backend/ppxc-login-window");
13
+ const ppxc_client_1 = require("../backend/ppxc-client");
14
+ const battle_report_1 = require("./battle-report");
15
+ const content_insights_1 = require("./content-insights");
16
+ const diagnostics_1 = require("./diagnostics");
17
+ const version_1 = require("../version");
18
+ const PLATFORM_ZOD = zod_1.z.enum(["douyin", "xiaohongshu", "kuaishou"]);
19
+ function jsonText(payload) {
20
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
21
+ }
22
+ function sleepMs(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+ const KEYWORD_CATEGORY_LABELS = {
26
+ generic: "泛需求词",
27
+ experience: "体验感受词",
28
+ persona_pain: "人群痛点词",
29
+ decision: "决策对比词",
30
+ problem_failure: "问题翻车词",
31
+ };
32
+ const KEYWORD_SOURCE_LABELS = {
33
+ main_committee: "AI 精选",
34
+ rule_based: "规则兜底",
35
+ custom: "用户自定义",
36
+ };
37
+ function rankCommitteeKeywords(keywords) {
38
+ const tier = (k) => {
39
+ if (k.source === "main_committee") {
40
+ return k.priority === "main" ? 0 : k.priority === "supplement" ? 2 : 1;
41
+ }
42
+ if (k.source === "custom")
43
+ return 3;
44
+ return 4;
45
+ };
46
+ return [...keywords].sort((a, b) => tier(a) - tier(b));
47
+ }
48
+ function toReportLead(lead, extra) {
49
+ return {
50
+ nickname: lead.nickname,
51
+ intent: lead.intent,
52
+ demandType: lead.demandType,
53
+ comment: lead.comment,
54
+ reason: lead.reason,
55
+ script: lead.script,
56
+ saleScore: lead.saleScore,
57
+ ipLabel: lead.ipLabel || undefined,
58
+ profileUrl: lead.contact?.profileUrl || undefined,
59
+ fromContent: extra?.fromContent,
60
+ fromKeyword: extra?.fromKeyword,
61
+ };
62
+ }
63
+ async function productNameOf(productId) {
64
+ try {
65
+ const products = await (0, ppxc_client_1.listProducts)();
66
+ return products.find((p) => p.id === productId)?.name;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ function topPickOf(reportLeads) {
73
+ const sorted = [...reportLeads].sort((a, b) => (b.saleScore ?? 0) - (a.saleScore ?? 0));
74
+ const top = sorted[0];
75
+ if (!top)
76
+ return undefined;
77
+ return { nickname: top.nickname || "(未留昵称)", why: (0, battle_report_1.rankReason)(top) };
78
+ }
79
+ function formatCommitteeKeywords(keywords, cap = 40) {
80
+ return rankCommitteeKeywords(keywords)
81
+ .slice(0, cap)
82
+ .map((k) => ({
83
+ keyword: k.keyword,
84
+ source: KEYWORD_SOURCE_LABELS[k.source] ?? k.source,
85
+ ...(k.category ? { category: KEYWORD_CATEGORY_LABELS[k.category] ?? k.category } : {}),
86
+ ...(k.priority
87
+ ? { priority: k.priority === "main" ? "主力" : k.priority === "supplement" ? "补充" : "普通" }
88
+ : {}),
89
+ ...(k.reason ? { reason: k.reason } : {}),
90
+ }));
91
+ }
92
+ const RUNNER_HINTS = {
93
+ LOGIN_REQUIRED: "还没登录。请先用 check_status_and_login 对应平台的登录动作扫码,再重试。",
94
+ VERIFICATION_REQUIRED: "平台要求验证,已弹出窗口。请完成验证后再重试,不要换词硬刷。",
95
+ FETCH_TIMEOUT: "评论没拉回来,可能是网络或风控。稍等一下再试。",
96
+ BAD_VIDEO_LINK: "链接无法识别,请确认是有效的内容链接。",
97
+ EMPTY_COMMENTS: "暂时没有可分析的评论。",
98
+ BUSY: "上一个任务还在进行中,请等它结束后再发起新的。",
99
+ RATE_LIMITED: "为保护账号安全,两次抓取之间需要间隔半分钟。",
100
+ DAILY_LIMITED: "今天的安全抓取额度已用完,请明天再继续。",
101
+ NO_SEARCH_RESULT: "没搜到合适的内容,换个词或稍后再试。",
102
+ INTERNAL: "内部出错了,请稍后重试。",
103
+ };
104
+ function runnerErrorText(err, platform) {
105
+ const name = (0, registry_1.getPlatformAdapter)(platform).displayName;
106
+ const hints = {
107
+ ...RUNNER_HINTS,
108
+ LOGIN_REQUIRED: `${name}还没登录。请先用 check_status_and_login 登录 ${name},再重试。`,
109
+ VERIFICATION_REQUIRED: `${name}要求验证,已弹出窗口。请完成验证后再重试。`,
110
+ BAD_VIDEO_LINK: `这个链接里没认出${name}内容,请确认链接正确。`,
111
+ DAILY_LIMITED: `今天 ${name} 的安全抓取额度已用完,请明天再继续。`,
112
+ };
113
+ return {
114
+ ok: false,
115
+ code: err.code,
116
+ userHint: hints[err.code] ?? "出错了,请稍后重试。",
117
+ detail: err.message,
118
+ };
119
+ }
120
+ async function allPlatformLoginStatus() {
121
+ const out = {};
122
+ for (const adapter of (0, registry_1.listPlatformAdapters)()) {
123
+ try {
124
+ const st = await (0, platform_runner_1.getLoginStatus)(adapter.id);
125
+ out[adapter.id] = st.loggedIn;
126
+ }
127
+ catch {
128
+ out[adapter.id] = false;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+ function createMcpServer() {
134
+ const server = new mcp_js_1.McpServer({
135
+ name: "ppxc-leads-mcp",
136
+ version: version_1.OWN_VERSION,
137
+ });
138
+ const registerTool = server.registerTool.bind(server);
139
+ registerTool("check_status_and_login", {
140
+ description: "检查抖音/小红书/快手 + PPXC 登录态。action=login_douyin|login_xiaohongshu|login_kuaishou 弹对应平台扫码窗;action=login_ppxc 弹 PPXC 账号登录窗;默认只查状态。",
141
+ inputSchema: {
142
+ action: zod_1.z
143
+ .enum([
144
+ "status",
145
+ "login_douyin",
146
+ "login_xiaohongshu",
147
+ "login_kuaishou",
148
+ "login_ppxc",
149
+ ])
150
+ .optional()
151
+ .describe("status=只查;login_*=弹对应平台登录窗;login_ppxc=PPXC 账号登录。"),
152
+ },
153
+ }, async (args) => {
154
+ const action = args.action ?? "status";
155
+ try {
156
+ const loginPlatformMap = {
157
+ login_douyin: "douyin",
158
+ login_xiaohongshu: "xiaohongshu",
159
+ login_kuaishou: "kuaishou",
160
+ };
161
+ if (action in loginPlatformMap) {
162
+ const platform = loginPlatformMap[action];
163
+ const adapter = (0, registry_1.getPlatformAdapter)(platform);
164
+ const result = await (0, platform_runner_1.startPlatformLogin)(platform);
165
+ const platforms = await allPlatformLoginStatus();
166
+ return jsonText({
167
+ ok: true,
168
+ platform,
169
+ loggedIn: result.loggedIn,
170
+ platforms,
171
+ ppxcLoggedIn: (0, ppxc_login_window_1.isPpxcLoggedIn)(),
172
+ hint: result.loggedIn
173
+ ? `${adapter.displayName}已登录。`
174
+ : `还没检测到${adapter.displayName}登录成功。请在弹出窗口扫码,扫完再调用本工具确认。`,
175
+ logFile: logger_1.LOG_FILE_PATH,
176
+ });
177
+ }
178
+ if (action === "login_ppxc") {
179
+ const result = await (0, ppxc_login_window_1.openPpxcLoginWindow)();
180
+ const platforms = await allPlatformLoginStatus();
181
+ return jsonText({
182
+ ok: result.ok,
183
+ platforms,
184
+ ppxcLoggedIn: (0, ppxc_login_window_1.isPpxcLoggedIn)(),
185
+ hint: result.ok
186
+ ? "PPXC 账号已登录。"
187
+ : `PPXC 登录未完成:${result.message ?? "请重试"}`,
188
+ logFile: logger_1.LOG_FILE_PATH,
189
+ });
190
+ }
191
+ const platforms = await allPlatformLoginStatus();
192
+ const ppxc = (0, ppxc_login_window_1.isPpxcLoggedIn)();
193
+ const missing = [];
194
+ for (const adapter of (0, registry_1.listPlatformAdapters)()) {
195
+ if (!platforms[adapter.id]) {
196
+ missing.push(`${adapter.displayName}(action=login_${adapter.id === "xiaohongshu" ? "xiaohongshu" : adapter.id})`);
197
+ }
198
+ }
199
+ if (!ppxc)
200
+ missing.push("PPXC 账号(action=login_ppxc)");
201
+ return jsonText({
202
+ ok: true,
203
+ platforms,
204
+ ppxcLoggedIn: ppxc,
205
+ hint: missing.length === 0
206
+ ? "各平台与 PPXC 都已登录,可以开始找客户了。"
207
+ : `还需要登录:${missing.join("、")}`,
208
+ logFile: logger_1.LOG_FILE_PATH,
209
+ });
210
+ }
211
+ catch (err) {
212
+ if (err instanceof platform_runner_1.RunnerError && err.code === "BUSY") {
213
+ return jsonText({
214
+ ok: false,
215
+ code: "BUSY",
216
+ userHint: "上一个任务还在进行中,请等它结束后再操作登录。",
217
+ detail: err.message,
218
+ });
219
+ }
220
+ return jsonText({
221
+ ok: false,
222
+ code: "INTERNAL",
223
+ userHint: "检查登录态时出错了,请稍后重试。",
224
+ detail: err instanceof Error ? err.message : String(err),
225
+ });
226
+ }
227
+ });
228
+ registerTool("list_products", {
229
+ description: "列出当前 PPXC 账号下的产品,返回 id 和名称。分析评论前用它拿到 productId。",
230
+ inputSchema: {},
231
+ }, async () => {
232
+ try {
233
+ const products = await (0, ppxc_client_1.listProducts)();
234
+ return jsonText({
235
+ ok: true,
236
+ products,
237
+ hint: products.length === 0
238
+ ? "这个账号下还没有产品,请先到 PPXC 网页端创建一个产品。"
239
+ : "把其中一个产品的 id 作为 productId 传给 analyze_video_comments。",
240
+ });
241
+ }
242
+ catch (err) {
243
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 401) {
244
+ return jsonText({
245
+ ok: false,
246
+ code: "PPXC_LOGIN_REQUIRED",
247
+ userHint: "PPXC 未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
248
+ });
249
+ }
250
+ return jsonText({
251
+ ok: false,
252
+ code: "INTERNAL",
253
+ userHint: "获取产品列表失败,请稍后重试。",
254
+ detail: err instanceof Error ? err.message : String(err),
255
+ });
256
+ }
257
+ });
258
+ registerTool("analyze_video_comments", {
259
+ description: "给一条抖音/小红书/快手内容链接 + productId:读评论 → PPXC AI 分析 → 客户战报 + 落客户池。platform 可省略,会从链接自动识别。" +
260
+ "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
261
+ "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
262
+ inputSchema: {
263
+ videoUrl: zod_1.z.string().describe("内容链接(抖音视频/小红书笔记/快手视频)或 ID。"),
264
+ productId: zod_1.z.string().describe("产品 id,用 list_products 获取。"),
265
+ platform: PLATFORM_ZOD
266
+ .optional()
267
+ .describe("可选:douyin | xiaohongshu | kuaishou。不传则从链接自动识别。"),
268
+ maxComments: zod_1.z.number().int().min(1).max(50).optional().describe("最多读多少条评论,默认 30。"),
269
+ save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
270
+ },
271
+ }, async (args) => {
272
+ const videoUrl = args.videoUrl;
273
+ const productId = args.productId;
274
+ const maxComments = args.maxComments;
275
+ const save = args.save;
276
+ const platform = (0, detect_platform_1.normalizePlatformId)(args.platform, videoUrl);
277
+ if (!productId || typeof productId !== "string") {
278
+ return jsonText({
279
+ ok: false,
280
+ code: "NO_PRODUCT",
281
+ userHint: "缺少 productId。请先用 list_products 选一个产品。",
282
+ });
283
+ }
284
+ let fetched;
285
+ try {
286
+ fetched = await (0, platform_runner_1.fetchContentComments)(platform, videoUrl, maxComments ?? 30);
287
+ }
288
+ catch (err) {
289
+ if (err instanceof platform_runner_1.RunnerError) {
290
+ return jsonText(runnerErrorText(err, platform));
291
+ }
292
+ return jsonText({
293
+ ok: false,
294
+ code: "INTERNAL",
295
+ userHint: "拉评论时内部出错,请稍后重试。",
296
+ detail: err instanceof Error ? err.message : String(err),
297
+ });
298
+ }
299
+ if (fetched.comments.length === 0) {
300
+ return jsonText({
301
+ ok: false,
302
+ code: "EMPTY_COMMENTS",
303
+ userHint: "这条内容暂时没有可分析的评论。",
304
+ content: { id: fetched.contentId, url: fetched.contentUrl, platform },
305
+ });
306
+ }
307
+ try {
308
+ const analysis = await (0, ppxc_client_1.analyzeComments)({
309
+ productId,
310
+ comments: fetched.comments,
311
+ videoUrl: fetched.contentUrl,
312
+ save: save !== false,
313
+ });
314
+ const reportLeads = analysis.leads.map((l) => toReportLead(l, { fromContent: fetched.contentUrl }));
315
+ const topPick = topPickOf(reportLeads);
316
+ const paywall = analysis.paywall;
317
+ const paywallLocked = paywall?.locked === true && (paywall.lockedCount ?? 0) > 0;
318
+ let report = {};
319
+ if (reportLeads.length > 0) {
320
+ const exported = (0, battle_report_1.exportBattleReport)({
321
+ kind: "link",
322
+ platformName: (0, registry_1.getPlatformAdapter)(platform).displayName,
323
+ productName: await productNameOf(productId),
324
+ contentUrl: fetched.contentUrl,
325
+ summary: analysis.summary,
326
+ leads: reportLeads,
327
+ savedToPool: analysis.saved,
328
+ skippedDuplicates: analysis.skippedDuplicates,
329
+ paywall: paywallLocked
330
+ ? {
331
+ lockedCount: paywall.lockedCount ?? 0,
332
+ lockedIntents: paywall.lockedIntents,
333
+ unlockHint: paywall.unlockHint,
334
+ }
335
+ : undefined,
336
+ });
337
+ if (exported.ok)
338
+ report = { file: exported.file };
339
+ }
340
+ const baseVerdict = analysis.summary.demandsFound > 0
341
+ ? `看了 ${analysis.summary.commentsAnalyzed} 条评论,挑出 ${analysis.summary.demandsFound} 个潜在客户,其中 ${analysis.summary.highIntentCount} 个意向较高。`
342
+ : `看了 ${analysis.summary.commentsAnalyzed} 条评论,暂时没看到明确的潜在客户。`;
343
+ const lockedTail = paywallLocked
344
+ ? `你当前是体验版,只解锁了前 ${reportLeads.length} 个完整客户;${paywall.unlockHint}`
345
+ : "";
346
+ return jsonText({
347
+ ok: true,
348
+ platform,
349
+ content: { id: fetched.contentId, url: fetched.contentUrl },
350
+ summary: {
351
+ ...analysis.summary,
352
+ commentsFetched: fetched.comments.length,
353
+ verdict: (topPick ? `${baseVerdict}首推「${topPick.nickname}」(${topPick.why})。` : baseVerdict) +
354
+ (lockedTail ? `(${lockedTail})` : ""),
355
+ },
356
+ topPick,
357
+ paywall,
358
+ contentAngles: (0, content_insights_1.deriveContentAngles)(reportLeads),
359
+ leads: analysis.leads,
360
+ savedToPool: analysis.saved,
361
+ skippedDuplicates: analysis.skippedDuplicates ?? 0,
362
+ saveFailed: analysis.saveFailed === true,
363
+ ...(report.file
364
+ ? {
365
+ reportFile: report.file,
366
+ reportHint: paywallLocked
367
+ ? `已生成战报(网页文件),里面只展示了解锁的前 ${reportLeads.length} 个客户;其余已锁,开通套餐后全部解锁。文件在:${report.file}`
368
+ : `已生成一份客户战报(网页文件,含话术,可转发同事照着跟进),放在:${report.file}`,
369
+ }
370
+ : {}),
371
+ });
372
+ }
373
+ catch (err) {
374
+ if (err instanceof ppxc_client_1.PpxcApiError) {
375
+ if (err.status === 401) {
376
+ return jsonText({
377
+ ok: false,
378
+ code: "PPXC_LOGIN_REQUIRED",
379
+ userHint: "PPXC 未登录或登录失效,请用 check_status_and_login(action=login_ppxc)登录后重试。",
380
+ });
381
+ }
382
+ if (err.status === 402) {
383
+ return jsonText({
384
+ ok: false,
385
+ code: "INSUFFICIENT_CREDITS",
386
+ userHint: `${err.message}。请到 PPXC 网页端给 AI 充电后再试。`,
387
+ });
388
+ }
389
+ if (err.status === 403) {
390
+ return jsonText({
391
+ ok: false,
392
+ code: "NO_PRODUCT_ACCESS",
393
+ userHint: "这个产品不属于当前账号,请用 list_products 重新选一个。",
394
+ });
395
+ }
396
+ if (err.status === 429) {
397
+ return jsonText({
398
+ ok: false,
399
+ code: "BACKEND_RATE_LIMITED",
400
+ userHint: `后端提示请求太频繁:${err.message}`,
401
+ });
402
+ }
403
+ return jsonText({ ok: false, code: "BACKEND_ERROR", userHint: `后端分析失败:${err.message}` });
404
+ }
405
+ return jsonText({
406
+ ok: false,
407
+ code: "INTERNAL",
408
+ userHint: "送后端分析时内部出错,请稍后重试。",
409
+ detail: err instanceof Error ? err.message : String(err),
410
+ });
411
+ }
412
+ });
413
+ registerTool("search_keyword_for_leads", {
414
+ description: "关键词 + productId + platform:搜内容 → 读评论 → AI 分析 → 汇总战报。支持 douyin/xiaohongshu/kuaishou。多关键词 2 窗口并行(抖音);小红书/快手更保守。" +
415
+ "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
416
+ "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
417
+ inputSchema: {
418
+ platform: PLATFORM_ZOD.describe("在哪个平台搜:douyin | xiaohongshu | kuaishou。"),
419
+ keywords: zod_1.z
420
+ .array(zod_1.z.string())
421
+ .min(1)
422
+ .max(6)
423
+ .describe("搜索词列表,最多 6 个。"),
424
+ productId: zod_1.z.string().describe("产品 id,用 list_products 获取。"),
425
+ maxVideosPerKeyword: zod_1.z
426
+ .number()
427
+ .int()
428
+ .min(1)
429
+ .max(8)
430
+ .optional()
431
+ .describe("每个关键词最多读几条内容;合计有平台上限(抖音 12、小红书/快手 8)。"),
432
+ save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
433
+ },
434
+ }, async (args) => {
435
+ const platform = (0, detect_platform_1.normalizePlatformId)(args.platform);
436
+ const keywords = args.keywords ?? [];
437
+ const productId = args.productId;
438
+ const maxVideosPerKeyword = args.maxVideosPerKeyword;
439
+ const save = args.save;
440
+ const adapter = (0, registry_1.getPlatformAdapter)(platform);
441
+ if (!productId || typeof productId !== "string") {
442
+ return jsonText({ ok: false, code: "NO_PRODUCT", userHint: "缺少 productId。" });
443
+ }
444
+ if (!Array.isArray(keywords) || keywords.length === 0) {
445
+ return jsonText({ ok: false, code: "NO_KEYWORD", userHint: "缺少搜索词。" });
446
+ }
447
+ let outcomes;
448
+ try {
449
+ outcomes = await (0, platform_runner_1.searchKeywordsBatch)(platform, keywords, maxVideosPerKeyword ?? 5);
450
+ }
451
+ catch (err) {
452
+ if (err instanceof platform_runner_1.RunnerError) {
453
+ return jsonText(runnerErrorText(err, platform));
454
+ }
455
+ return jsonText({
456
+ ok: false,
457
+ code: "INTERNAL",
458
+ userHint: "搜索时内部出错,请稍后重试。",
459
+ detail: err instanceof Error ? err.message : String(err),
460
+ });
461
+ }
462
+ const allLeads = [];
463
+ let commentsAnalyzed = 0;
464
+ let demandsFound = 0;
465
+ let highIntentCount = 0;
466
+ let savedToPool = 0;
467
+ let skippedDuplicates = 0;
468
+ let saveFailedCount = 0;
469
+ let itemsRead = 0;
470
+ let lockedFromBackend = 0;
471
+ let anyLocked = false;
472
+ const perKeyword = [];
473
+ for (const oc of outcomes) {
474
+ if (!oc.ok || !oc.result) {
475
+ perKeyword.push({ keyword: oc.keyword, ok: false, code: oc.errorCode });
476
+ continue;
477
+ }
478
+ let kwDemands = 0;
479
+ const contentItems = oc.result.items ?? oc.result.videos ?? [];
480
+ for (const v of contentItems) {
481
+ itemsRead += 1;
482
+ const itemUrl = v.contentUrl ?? v.videoUrl ?? "";
483
+ try {
484
+ const analysis = await (0, ppxc_client_1.analyzeComments)({
485
+ productId,
486
+ comments: v.comments,
487
+ videoUrl: itemUrl,
488
+ videoDesc: v.title,
489
+ save: save !== false,
490
+ });
491
+ commentsAnalyzed += analysis.summary.commentsAnalyzed;
492
+ demandsFound += analysis.summary.demandsFound;
493
+ kwDemands += analysis.summary.demandsFound;
494
+ highIntentCount += analysis.summary.highIntentCount;
495
+ savedToPool += analysis.saved;
496
+ skippedDuplicates += analysis.skippedDuplicates ?? 0;
497
+ if (analysis.saveFailed === true)
498
+ saveFailedCount += 1;
499
+ lockedFromBackend += analysis.paywall?.lockedCount ?? 0;
500
+ if (analysis.paywall?.locked)
501
+ anyLocked = true;
502
+ for (const lead of analysis.leads) {
503
+ allLeads.push({ ...lead, fromContent: itemUrl, fromKeyword: oc.keyword, platform });
504
+ }
505
+ }
506
+ catch (err) {
507
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 401) {
508
+ return jsonText({
509
+ ok: false,
510
+ code: "PPXC_LOGIN_REQUIRED",
511
+ userHint: "PPXC 未登录或登录失效,请用 check_status_and_login(action=login_ppxc)登录后重试。",
512
+ });
513
+ }
514
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 402) {
515
+ return jsonText({
516
+ ok: false,
517
+ code: "INSUFFICIENT_CREDITS",
518
+ userHint: `${err.message}。请到 PPXC 网页端给 AI 充电后再试。`,
519
+ partial: allLeads.length > 0
520
+ ? { leads: allLeads, commentsAnalyzed, demandsFound, savedToPool }
521
+ : undefined,
522
+ });
523
+ }
524
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 403) {
525
+ return jsonText({
526
+ ok: false,
527
+ code: "NO_PRODUCT_ACCESS",
528
+ userHint: "这个产品不属于当前账号,请用 list_products 重新选一个。",
529
+ });
530
+ }
531
+ }
532
+ }
533
+ perKeyword.push({
534
+ keyword: oc.keyword,
535
+ ok: true,
536
+ itemsRead: contentItems.length,
537
+ demands: kwDemands,
538
+ });
539
+ }
540
+ const hitVerification = outcomes.some((o) => !o.ok && o.errorCode === "VERIFICATION_REQUIRED");
541
+ const hitLoginRequired = outcomes.some((o) => !o.ok && o.errorCode === "LOGIN_REQUIRED");
542
+ if (commentsAnalyzed === 0) {
543
+ if (hitVerification) {
544
+ return jsonText({
545
+ ok: false,
546
+ code: "VERIFICATION_REQUIRED",
547
+ userHint: `${adapter.displayName}要求验证,已弹出窗口,本次搜索已停止。请先完成验证,不要换词重试。`,
548
+ perKeyword,
549
+ });
550
+ }
551
+ if (hitLoginRequired) {
552
+ return jsonText({
553
+ ok: false,
554
+ code: "LOGIN_REQUIRED",
555
+ userHint: `${adapter.displayName}登录已失效,请先登录后再重试。`,
556
+ perKeyword,
557
+ });
558
+ }
559
+ return jsonText({
560
+ ok: false,
561
+ code: "EMPTY_COMMENTS",
562
+ userHint: "搜到的内容没读到可分析的评论,换个词试试。",
563
+ perKeyword,
564
+ });
565
+ }
566
+ allLeads.sort((a, b) => (b.saleScore ?? 0) - (a.saleScore ?? 0));
567
+ const SEARCH_FREE_PREVIEW = 2;
568
+ const visibleAllLeads = anyLocked ? allLeads.slice(0, SEARCH_FREE_PREVIEW) : allLeads;
569
+ const lockedTotal = anyLocked
570
+ ? allLeads.length - visibleAllLeads.length + lockedFromBackend
571
+ : 0;
572
+ const paywallLocked = anyLocked && lockedTotal > 0;
573
+ const unlockHint = `还有 ${lockedTotal} 个潜在客户(含完整评论、跟进话术、主页链接)已找到,开通套餐后全部解锁。`;
574
+ const reportLeads = visibleAllLeads.map((l) => toReportLead(l, { fromContent: l.fromContent, fromKeyword: l.fromKeyword }));
575
+ const topPick = topPickOf(reportLeads);
576
+ let report = {};
577
+ if (reportLeads.length > 0) {
578
+ const exported = (0, battle_report_1.exportBattleReport)({
579
+ kind: "search",
580
+ platformName: adapter.displayName,
581
+ productName: await productNameOf(productId),
582
+ keywords: outcomes.map((o) => o.keyword),
583
+ summary: { itemsRead, commentsAnalyzed, demandsFound, highIntentCount },
584
+ leads: reportLeads,
585
+ perKeyword: perKeyword,
586
+ savedToPool,
587
+ skippedDuplicates,
588
+ paywall: paywallLocked ? { lockedCount: lockedTotal, unlockHint } : undefined,
589
+ });
590
+ if (exported.ok)
591
+ report = { file: exported.file };
592
+ }
593
+ const kwLabel = outcomes.map((o) => o.keyword).join("、");
594
+ const warning = hitVerification
595
+ ? `注意:搜索中途${adapter.displayName}要求验证,部分关键词未完成,请先验证再继续。`
596
+ : undefined;
597
+ let baseVerdict = demandsFound > 0
598
+ ? `在${adapter.displayName}搜「${kwLabel}」读了 ${itemsRead} 条内容共 ${commentsAnalyzed} 条评论,挑出 ${demandsFound} 个潜在客户,其中 ${highIntentCount} 个意向较高。`
599
+ : `在${adapter.displayName}搜「${kwLabel}」读了 ${itemsRead} 条内容,暂时没看到明确的潜在客户。`;
600
+ if (topPick)
601
+ baseVerdict += `首推「${topPick.nickname}」(${topPick.why})。`;
602
+ const lockedTail = paywallLocked
603
+ ? `你当前是体验版,只解锁了前 ${visibleAllLeads.length} 个完整客户;${unlockHint}`
604
+ : "";
605
+ return jsonText({
606
+ ok: true,
607
+ platform,
608
+ keywords: outcomes.map((o) => o.keyword),
609
+ warning,
610
+ summary: {
611
+ keywordsSearched: outcomes.length,
612
+ itemsRead,
613
+ commentsAnalyzed,
614
+ demandsFound,
615
+ highIntentCount,
616
+ verdict: (warning ? `${baseVerdict}(${warning})` : baseVerdict) +
617
+ (lockedTail ? `(${lockedTail})` : ""),
618
+ },
619
+ topPick,
620
+ paywall: paywallLocked ? { locked: true, lockedCount: lockedTotal, unlockHint } : { locked: false },
621
+ contentAngles: (0, content_insights_1.deriveContentAngles)(reportLeads),
622
+ leads: visibleAllLeads,
623
+ perKeyword,
624
+ savedToPool,
625
+ skippedDuplicates,
626
+ saveFailed: saveFailedCount > 0,
627
+ ...(report.file
628
+ ? {
629
+ reportFile: report.file,
630
+ reportHint: paywallLocked
631
+ ? `已生成战报(网页文件),只展示了解锁的前 ${visibleAllLeads.length} 个客户;其余已锁,开通套餐后全部解锁。文件在:${report.file}`
632
+ : `已生成一份客户战报(网页文件,含每个词的成绩和话术,可转发同事照着跟进),放在:${report.file}`,
633
+ }
634
+ : {}),
635
+ });
636
+ });
637
+ registerTool("suggest_search_keywords", {
638
+ description: "给 productId,返回 PPXC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。" +
639
+ "词单通常在产品创建/编辑时已自动生成,本工具默认直接读现成结果;没有现成词单时会触发一轮生成并等待(约 1 分钟)。" +
640
+ "开搜前先调它,从主力词里挑 3~6 个传给 search_keyword_for_leads,比现场编词命中率高得多。" +
641
+ "regenerate=true 强制重新生成(消耗用户电力,仅当用户明确要求换一批词时使用)。",
642
+ inputSchema: {
643
+ productId: zod_1.z.string().describe("产品 id,用 list_products 获取。"),
644
+ regenerate: zod_1.z
645
+ .boolean()
646
+ .optional()
647
+ .describe("强制重新生成一轮(会消耗电力)。默认 false:直接读现成词单。"),
648
+ },
649
+ }, async (args) => {
650
+ const productId = args.productId;
651
+ const regenerate = args.regenerate === true;
652
+ if (!productId || typeof productId !== "string") {
653
+ return jsonText({ ok: false, code: "NO_PRODUCT", userHint: "缺少 productId。请先用 list_products 选一个产品。" });
654
+ }
655
+ try {
656
+ let current = await (0, ppxc_client_1.getCommitteeKeywords)(productId);
657
+ const needTrigger = regenerate ||
658
+ (current.keywords.length === 0 &&
659
+ current.generationStatus !== "running" &&
660
+ current.generationStatus !== "queued");
661
+ if (needTrigger) {
662
+ await (0, ppxc_client_1.triggerCommitteeRun)(productId);
663
+ }
664
+ const shouldWait = needTrigger || current.generationStatus === "running" || current.generationStatus === "queued";
665
+ if (shouldWait) {
666
+ const deadline = Date.now() + 100000;
667
+ do {
668
+ await sleepMs(8000);
669
+ current = await (0, ppxc_client_1.getCommitteeKeywords)(productId);
670
+ } while (Date.now() < deadline &&
671
+ current.generationStatus !== "done" &&
672
+ current.generationStatus !== "error");
673
+ }
674
+ if (current.keywords.length === 0) {
675
+ if (current.generationStatus === "error") {
676
+ return jsonText({
677
+ ok: false,
678
+ code: "COMMITTEE_FAILED",
679
+ userHint: `这一轮想词没跑出来${current.errorMessage ? `(${current.errorMessage})` : ""}。可以稍后用 regenerate=true 再试一次。`,
680
+ });
681
+ }
682
+ return jsonText({
683
+ ok: false,
684
+ code: "COMMITTEE_PENDING",
685
+ userHint: "搜词还在后台生成,请过一两分钟再调用本工具读取结果。",
686
+ });
687
+ }
688
+ const list = formatCommitteeKeywords(current.keywords);
689
+ const mainCount = list.filter((k) => k.priority === "主力" || k.source === "AI 精选").length;
690
+ return jsonText({
691
+ ok: true,
692
+ generationStatus: current.generationStatus,
693
+ total: current.keywords.length,
694
+ keywords: list,
695
+ hint: `共 ${current.keywords.length} 个搜索词(返回前 ${list.length} 个,AI 精选 ${mainCount} 个在前)。` +
696
+ "建议从靠前的主力词里挑 3~6 个传给 search_keyword_for_leads;注意每个平台有每日安全额度(抖音 20 词、小红书/快手各 10 词),先小批验证哪些词出客户,再决定是否加词。",
697
+ });
698
+ }
699
+ catch (err) {
700
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 401) {
701
+ return jsonText({
702
+ ok: false,
703
+ code: "PPXC_LOGIN_REQUIRED",
704
+ userHint: "PPXC 未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
705
+ });
706
+ }
707
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 403) {
708
+ return jsonText({ ok: false, code: "NO_PRODUCT_ACCESS", userHint: "这个产品不属于当前账号,请用 list_products 重新选一个。" });
709
+ }
710
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 503) {
711
+ return jsonText({ ok: false, code: "COMMITTEE_DISABLED", userHint: "想词功能暂未对这个产品开放,请直接根据产品和目标人群拟 3~6 个搜索词使用。" });
712
+ }
713
+ return jsonText({
714
+ ok: false,
715
+ code: "INTERNAL",
716
+ userHint: "获取搜索词时出错了,请稍后重试。",
717
+ detail: err instanceof Error ? err.message : String(err),
718
+ });
719
+ }
720
+ });
721
+ registerTool("query_leads", {
722
+ description: "查 PPXC 客户池里已挖到的潜在客户(只读)。支持按产品、来源搜索词、最近天数、跟进状态过滤," +
723
+ "默认返回最新 30 条,每条带昵称、评论原文、意向、判断理由、跟进话术、跟进状态。" +
724
+ "用户问「之前找到的客户 / 高意向的有哪些 / 某个词搜出来的客户」时用它。",
725
+ inputSchema: {
726
+ productId: zod_1.z.string().optional().describe("产品 id;不传则查当前账号全部产品。"),
727
+ keyword: zod_1.z.string().optional().describe("只看这个搜索词挖到的客户。"),
728
+ days: zod_1.z.number().int().min(1).max(90).optional().describe("只看最近 N 天入池的客户。"),
729
+ status: zod_1.z
730
+ .enum(["待处理", "已联系", "已转化", "未转化", "忽略"])
731
+ .optional()
732
+ .describe("按跟进状态过滤。"),
733
+ limit: zod_1.z.number().int().min(1).max(100).optional().describe("最多返回几条,默认 30。"),
734
+ },
735
+ }, async (args) => {
736
+ const productId = args.productId;
737
+ const keyword = args.keyword;
738
+ const days = args.days;
739
+ const status = args.status;
740
+ const limit = Math.max(1, Math.min(100, args.limit ?? 30));
741
+ try {
742
+ const since = days ? new Date(Date.now() - days * 86400000).toISOString() : undefined;
743
+ const { rows, paywallLocked } = await (0, ppxc_client_1.queryLeads)({ productId, keyword, since });
744
+ const filtered = status ? rows.filter((r) => r.status === status) : rows;
745
+ const byStatus = {};
746
+ const byIntent = {};
747
+ for (const r of filtered) {
748
+ byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
749
+ if (r.urgency)
750
+ byIntent[r.urgency] = (byIntent[r.urgency] ?? 0) + 1;
751
+ }
752
+ const leads = filtered.slice(0, limit).map((r) => ({
753
+ id: r.id,
754
+ nickname: r.user_nickname,
755
+ intent: r.urgency,
756
+ demandType: r.demand_type,
757
+ comment: r.comment_text,
758
+ reason: r.reason,
759
+ script: r.script,
760
+ status: r.status,
761
+ ...(r.source_keyword ? { fromKeyword: r.source_keyword } : {}),
762
+ ...(r.sale_score !== undefined ? { saleScore: r.sale_score } : {}),
763
+ ...(r.ip_label ? { region: r.ip_label } : {}),
764
+ ...(r.user_profile_url ? { profileUrl: r.user_profile_url } : {}),
765
+ videoUrl: r.video_url,
766
+ capturedAt: r.captured_at,
767
+ }));
768
+ const baseHint = filtered.length === 0
769
+ ? "没查到符合条件的客户。可以放宽条件,或先用 search_keyword_for_leads 去挖新客户。"
770
+ : `共 ${filtered.length} 条符合条件(按入池时间从新到旧,返回前 ${leads.length} 条)。完整客户池在 PPXC 网页端可看。`;
771
+ return jsonText({
772
+ ok: true,
773
+ totalMatched: filtered.length,
774
+ returned: leads.length,
775
+ byStatus,
776
+ byIntent,
777
+ paywall: paywallLocked ? { locked: true } : { locked: false },
778
+ leads,
779
+ hint: paywallLocked
780
+ ? `${baseHint}(你当前是体验版,名单里的跟进话术和联系方式已锁,开通套餐后解锁。)`
781
+ : baseHint,
782
+ });
783
+ }
784
+ catch (err) {
785
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 401) {
786
+ return jsonText({
787
+ ok: false,
788
+ code: "PPXC_LOGIN_REQUIRED",
789
+ userHint: "PPXC 未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
790
+ });
791
+ }
792
+ if (err instanceof ppxc_client_1.PpxcApiError && err.status === 403) {
793
+ return jsonText({ ok: false, code: "NO_PRODUCT_ACCESS", userHint: "这个产品不属于当前账号,请用 list_products 重新选一个。" });
794
+ }
795
+ return jsonText({
796
+ ok: false,
797
+ code: "INTERNAL",
798
+ userHint: "查询客户池时出错了,请稍后重试。",
799
+ detail: err instanceof Error ? err.message : String(err),
800
+ });
801
+ }
802
+ });
803
+ registerTool("export_diagnostics", {
804
+ description: "用户反馈「不好用 / 出错了 / 需要排查」时调用:把本机运行日志打包成一个诊断文件(默认放到桌面),告诉用户文件位置,让用户发给 PPXC 支持人员。不含账号密码等敏感信息。",
805
+ inputSchema: {},
806
+ }, async () => {
807
+ const result = (0, diagnostics_1.exportDiagnostics)();
808
+ if (!result.ok) {
809
+ return jsonText({
810
+ ok: false,
811
+ code: "INTERNAL",
812
+ userHint: "诊断文件没生成出来,请稍后再试一次。",
813
+ detail: result.error,
814
+ });
815
+ }
816
+ return jsonText({
817
+ ok: true,
818
+ file: result.file,
819
+ hint: `诊断文件已放到:${result.file}。请把这个文件发给 PPXC 支持人员,里面只有运行记录,没有账号密码。`,
820
+ });
821
+ });
822
+ return server;
823
+ }
824
+ async function startMcpServer() {
825
+ const server = createMcpServer();
826
+ const transport = new stdio_js_1.StdioServerTransport();
827
+ await server.connect(transport);
828
+ }
829
+ //# sourceMappingURL=server.js.map