ppxc-leads-mcp 0.1.11 → 0.1.12

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.
@@ -63,6 +63,19 @@ function randomDelay(adapter) {
63
63
  const { perItemMinDelayMs, perItemMaxDelayMs } = adapter.risk;
64
64
  return perItemMinDelayMs + Math.floor(Math.random() * (perItemMaxDelayMs - perItemMinDelayMs));
65
65
  }
66
+ async function emitSearchProgress(handler, event) {
67
+ if (!handler)
68
+ return;
69
+ try {
70
+ await handler(event);
71
+ }
72
+ catch (err) {
73
+ log.warn("search progress handler failed", {
74
+ stage: event.stage,
75
+ msg: err instanceof Error ? err.message : String(err),
76
+ });
77
+ }
78
+ }
66
79
  async function ensureOnContentPage(session, contentId, contentUrl) {
67
80
  const current = session.currentUrl();
68
81
  if (current.includes(contentId)) {
@@ -142,7 +155,7 @@ async function fetchContentCommentsInner(adapter, contentUrlOrId, maxComments) {
142
155
  hasMore: fetched.hasMore,
143
156
  };
144
157
  }
145
- async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPerItem = 30, slotId = SLOT_ID, shouldAbort) {
158
+ async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPerItem = 30, slotId = SLOT_ID, shouldAbort, onProgress) {
146
159
  const adapter = (0, registry_1.getPlatformAdapter)(platform);
147
160
  const kw = String(keyword ?? "").trim();
148
161
  if (!kw)
@@ -185,6 +198,7 @@ async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPe
185
198
  throw new RunnerError("NO_SEARCH_RESULT", `「${kw}」搜到的内容评论都太少,换个词试试`);
186
199
  }
187
200
  const items = [];
201
+ const readAttempts = [];
188
202
  let totalComments = 0;
189
203
  for (const it of selected) {
190
204
  if (shouldAbort?.()) {
@@ -193,11 +207,33 @@ async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPe
193
207
  }
194
208
  const contentId = String(it.contentId);
195
209
  const contentUrl = it.contentUrl || adapter.buildContentUrl(contentId);
210
+ const attempt = {
211
+ contentId,
212
+ contentUrl,
213
+ title: it.title ?? "",
214
+ authorName: it.authorName ?? "",
215
+ estimatedCommentCount: it.commentCount ?? 0,
216
+ status: "failed",
217
+ commentsFetched: 0,
218
+ };
219
+ readAttempts.push(attempt);
220
+ await emitSearchProgress(onProgress, {
221
+ stage: "content_selected",
222
+ platform,
223
+ keyword: kw,
224
+ contentId,
225
+ contentUrl,
226
+ title: attempt.title,
227
+ authorName: attempt.authorName,
228
+ estimatedCommentCount: attempt.estimatedCommentCount,
229
+ });
196
230
  try {
197
231
  await ensureOnContentPage(session, contentId, contentUrl);
198
232
  const fetched = await adapter.fetchComments(session, contentId, contentUrl, commentCount, DEFAULT_FETCH_WAIT_MS);
199
233
  if (fetched.json) {
200
234
  const comments = adapter.parseComments(contentId, fetched.json);
235
+ attempt.status = "ok";
236
+ attempt.commentsFetched = comments.length;
201
237
  totalComments += comments.length;
202
238
  items.push({
203
239
  contentId,
@@ -210,13 +246,28 @@ async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPe
210
246
  comments,
211
247
  });
212
248
  }
249
+ else {
250
+ attempt.error = "评论接口没有返回数据";
251
+ }
213
252
  }
214
253
  catch (err) {
254
+ attempt.error = err instanceof Error ? err.message : String(err);
215
255
  log.warn("search item comment fetch failed", {
216
256
  contentId,
217
- msg: err instanceof Error ? err.message : String(err),
257
+ msg: attempt.error,
218
258
  });
219
259
  }
260
+ await emitSearchProgress(onProgress, {
261
+ stage: "content_read",
262
+ platform,
263
+ keyword: kw,
264
+ contentId,
265
+ contentUrl,
266
+ title: attempt.title,
267
+ status: attempt.status === "ok" ? "ok" : "failed",
268
+ commentsFetched: attempt.commentsFetched,
269
+ ...(attempt.error ? { error: attempt.error } : {}),
270
+ });
220
271
  await sleep(randomDelay(adapter));
221
272
  }
222
273
  return {
@@ -224,11 +275,12 @@ async function searchKeywordForLeads(platform, keyword, maxItems = 5, commentsPe
224
275
  itemsFound: ranked.length,
225
276
  itemsRead: items.length,
226
277
  items,
278
+ readAttempts,
227
279
  videos: items,
228
280
  totalComments,
229
281
  };
230
282
  }
231
- async function searchKeywordsBatch(platform, keywords, maxItemsPerKeyword = 5, commentsPerItem = 30) {
283
+ async function searchKeywordsBatch(platform, keywords, maxItemsPerKeyword = 5, commentsPerItem = 30, onProgress) {
232
284
  const adapter = (0, registry_1.getPlatformAdapter)(platform);
233
285
  const cleaned = [];
234
286
  const seen = new Set();
@@ -250,6 +302,13 @@ async function searchKeywordsBatch(platform, keywords, maxItemsPerKeyword = 5, c
250
302
  }
251
303
  assertCrawlQuota(platform, cleaned.length);
252
304
  const perKeywordCap = Math.max(1, Math.min(Math.max(1, Math.min(adapter.risk.maxVideosPerKeyword, Math.floor(maxItemsPerKeyword) || 5)), Math.floor(adapter.risk.totalContentBudget / cleaned.length)));
305
+ await emitSearchProgress(onProgress, {
306
+ stage: "batch_start",
307
+ platform,
308
+ keywords: cleaned,
309
+ maxItemsPerKeyword: perKeywordCap,
310
+ commentsPerItem,
311
+ });
253
312
  const slotCount = Math.min(MAX_SLOTS, cleaned.length);
254
313
  const outcomes = [];
255
314
  let verificationHit = false;
@@ -263,8 +322,22 @@ async function searchKeywordsBatch(platform, keywords, maxItemsPerKeyword = 5, c
263
322
  continue;
264
323
  }
265
324
  try {
266
- const result = await searchKeywordForLeads(platform, kw, perKeywordCap, commentsPerItem, slotId, () => verificationHit);
325
+ await emitSearchProgress(onProgress, {
326
+ stage: "keyword_start",
327
+ platform,
328
+ keyword: kw,
329
+ });
330
+ const result = await searchKeywordForLeads(platform, kw, perKeywordCap, commentsPerItem, slotId, () => verificationHit, onProgress);
267
331
  outcomes.push({ keyword: kw, ok: true, result });
332
+ await emitSearchProgress(onProgress, {
333
+ stage: "keyword_done",
334
+ platform,
335
+ keyword: kw,
336
+ itemsFound: result.itemsFound,
337
+ itemsSelected: result.readAttempts?.length ?? result.items.length,
338
+ itemsRead: result.itemsRead,
339
+ commentsFetched: result.totalComments,
340
+ });
268
341
  }
269
342
  catch (err) {
270
343
  const code = err instanceof RunnerError ? err.code : "INTERNAL";
@@ -272,10 +345,23 @@ async function searchKeywordsBatch(platform, keywords, maxItemsPerKeyword = 5, c
272
345
  outcomes.push({ keyword: kw, ok: false, errorCode: code });
273
346
  if (code === "VERIFICATION_REQUIRED")
274
347
  verificationHit = true;
348
+ await emitSearchProgress(onProgress, {
349
+ stage: "keyword_failed",
350
+ platform,
351
+ keyword: kw,
352
+ errorCode: code,
353
+ });
275
354
  }
276
355
  }
277
356
  };
278
357
  await Promise.all(Array.from({ length: slotCount }, (_v, slotId) => runSlot(slotId)));
358
+ await emitSearchProgress(onProgress, {
359
+ stage: "batch_done",
360
+ platform,
361
+ keywords: cleaned,
362
+ completed: outcomes.filter((o) => o.ok).length,
363
+ failed: outcomes.filter((o) => !o.ok).length,
364
+ });
279
365
  return outcomes;
280
366
  });
281
367
  }
@@ -97,6 +97,35 @@ function hasProductContext(ctx) {
97
97
  function productContextHint() {
98
98
  return "先告诉我你卖的产品/服务名称,最好再补一句适合谁、解决什么问题;我可以先试跑一轮,给你看前 2 条线索。";
99
99
  }
100
+ function traceFromAttempt(attempt) {
101
+ return {
102
+ contentId: attempt.contentId,
103
+ url: attempt.contentUrl,
104
+ ...(attempt.title ? { title: attempt.title } : {}),
105
+ ...(attempt.authorName ? { authorName: attempt.authorName } : {}),
106
+ estimatedCommentCount: attempt.estimatedCommentCount,
107
+ commentsFetched: attempt.commentsFetched,
108
+ status: attempt.status === "ok" ? "read" : "failed",
109
+ ...(attempt.error ? { error: attempt.error } : {}),
110
+ };
111
+ }
112
+ function workflowTraceNarrative(input) {
113
+ const lines = [];
114
+ const kw = input.keywords?.length ? `,关键词:${input.keywords.join("、")}` : "";
115
+ lines.push(`本轮在${input.platformName}执行${input.keywords?.length ? "关键词搜索" : "单链接分析"}${kw}。`);
116
+ lines.push(`实际打开并读取了 ${input.itemsRead} 条内容,送 AI 分析 ${input.commentsAnalyzed} 条评论,识别出 ${input.demandsFound} 个潜在客户。`);
117
+ const visible = input.contents.slice(0, 8);
118
+ for (const item of visible) {
119
+ const title = item.title ? `《${item.title.slice(0, 40)}》` : item.contentId;
120
+ const demandText = typeof item.demandsFound === "number" ? `,识别 ${item.demandsFound} 个线索` : "";
121
+ const statusText = item.status === "read" ? `读了 ${item.commentsAnalyzed ?? item.commentsFetched} 条评论${demandText}` : `读取失败:${item.error ?? "未知原因"}`;
122
+ lines.push(`看过:${title},${statusText},链接:${item.url}`);
123
+ }
124
+ if (input.contents.length > visible.length) {
125
+ lines.push(`还有 ${input.contents.length - visible.length} 条内容已记录在 workflowTrace.contents 里。`);
126
+ }
127
+ return lines;
128
+ }
100
129
  function topPickOf(reportLeads) {
101
130
  const sorted = [...reportLeads].sort((a, b) => (b.saleScore ?? 0) - (a.saleScore ?? 0));
102
131
  const top = sorted[0];
@@ -117,6 +146,63 @@ function formatCommitteeKeywords(keywords, cap = 40) {
117
146
  ...(k.reason ? { reason: k.reason } : {}),
118
147
  }));
119
148
  }
149
+ function progressMessage(event, platformName) {
150
+ switch (event.stage) {
151
+ case "batch_start":
152
+ return `开始在${platformName}搜 ${event.keywords.length} 个关键词:${event.keywords.join("、")}`;
153
+ case "keyword_start":
154
+ return `开始搜「${event.keyword}」。`;
155
+ case "content_selected": {
156
+ const title = event.title ? `《${event.title.slice(0, 48)}》` : event.contentId;
157
+ return `打开候选内容 ${title},预估评论 ${event.estimatedCommentCount} 条,链接:${event.contentUrl}`;
158
+ }
159
+ case "content_read": {
160
+ const title = event.title ? `《${event.title.slice(0, 48)}》` : event.contentId;
161
+ if (event.status === "ok") {
162
+ return `读完 ${title},抓到 ${event.commentsFetched} 条评论。`;
163
+ }
164
+ return `这条内容读取失败:${title},原因:${event.error ?? "未知原因"}`;
165
+ }
166
+ case "keyword_done":
167
+ return `「${event.keyword}」完成:候选 ${event.itemsFound} 条,打开 ${event.itemsSelected} 条,读到 ${event.commentsFetched} 条评论。`;
168
+ case "keyword_failed":
169
+ return `「${event.keyword}」没有完成,错误码:${event.errorCode}`;
170
+ case "batch_done":
171
+ return `本轮搜索结束:完成 ${event.completed} 个关键词,失败 ${event.failed} 个。`;
172
+ }
173
+ }
174
+ function createSearchProgressReporter(extra, platformName) {
175
+ let progress = 0;
176
+ return async (event) => {
177
+ progress += 1;
178
+ const message = progressMessage(event, platformName);
179
+ try {
180
+ if (extra._meta?.progressToken !== undefined) {
181
+ await extra.sendNotification({
182
+ method: "notifications/progress",
183
+ params: {
184
+ progressToken: extra._meta.progressToken,
185
+ progress,
186
+ message,
187
+ },
188
+ });
189
+ }
190
+ await extra.sendNotification({
191
+ method: "notifications/message",
192
+ params: {
193
+ level: event.stage.endsWith("failed") ? "warning" : "info",
194
+ logger: "opc-comment-lead-radar",
195
+ data: {
196
+ message,
197
+ event,
198
+ },
199
+ },
200
+ });
201
+ }
202
+ catch {
203
+ }
204
+ };
205
+ }
120
206
  const RUNNER_HINTS = {
121
207
  LOGIN_REQUIRED: "平台还没登录。请用 check_status_and_login 对应平台的登录动作扫码,再重试。OPC 账号可等用户要解锁或保存完整名单时再登录。",
122
208
  VERIFICATION_REQUIRED: "平台要求验证,已弹出窗口。请完成验证后再重试,不要换词硬刷。",
@@ -296,8 +382,8 @@ function createMcpServer() {
296
382
  ok: true,
297
383
  products,
298
384
  hint: products.length === 0
299
- ? "这个账号下还没有产品,请先到 OPC 网页端创建一个产品。"
300
- : "把其中一个产品的 id 作为 productId 传给 analyze_video_comments。",
385
+ ? "这个账号下还没有已保存产品。不要因此中断试用扫描;请让用户给产品/服务名称和搜索词,直接调用 search_keyword_for_leads 或 analyze_video_comments。只有用户要保存完整名单、查客户池或长期复盘时,再引导他到 OPC 创建产品。"
386
+ : "只有用户明确要使用已保存产品时,才把其中一个产品的 id 作为 productId;普通试用扫描仍可直接传 productName/productDescription。",
301
387
  });
302
388
  }
303
389
  catch (err) {
@@ -305,7 +391,7 @@ function createMcpServer() {
305
391
  return jsonText({
306
392
  ok: false,
307
393
  code: "PPXC_LOGIN_REQUIRED",
308
- userHint: "OPC 账号未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
394
+ userHint: "产品列表只用于已登录完整模式。若用户只是试用扫描,不要先登录 OPC;请收集 productName/productDescription 和搜索词,直接调用 search_keyword_for_leads。",
309
395
  });
310
396
  }
311
397
  return jsonText({
@@ -318,7 +404,7 @@ function createMcpServer() {
318
404
  });
319
405
  registerTool("analyze_video_comments", {
320
406
  description: "给一条抖音/小红书/快手内容链接 + productId 或 productName:读评论 → OPC AI 分析 → 客户战报。未登录用户可只传 productName 先试跑,返回前 2 条线索;登录后传 productId 才会落客户池。platform 可省略,会从链接自动识别。" +
321
- "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
407
+ "工具会通过 MCP 通知实时汇报进度,并在结果里返回 workflowTrace/processNarrative。reportFile 只是本机临时战报备份,不是解锁入口;用户要看剩余线索或保存完整名单时,应引导登录 OPC 网页端客户池。" +
322
408
  "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
323
409
  inputSchema: {
324
410
  videoUrl: zod_1.z.string().describe("内容链接(抖音视频/小红书笔记/快手视频)或 ID。"),
@@ -334,12 +420,14 @@ function createMcpServer() {
334
420
  maxComments: zod_1.z.number().int().min(1).max(50).optional().describe("最多读多少条评论,默认 30。"),
335
421
  save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
336
422
  },
337
- }, async (args) => {
423
+ }, async (args, extra) => {
338
424
  const videoUrl = args.videoUrl;
339
425
  const productContext = productContextFromArgs(args);
340
426
  const maxComments = args.maxComments;
341
427
  const save = args.save;
342
428
  const platform = (0, detect_platform_1.normalizePlatformId)(args.platform, videoUrl);
429
+ const adapter = (0, registry_1.getPlatformAdapter)(platform);
430
+ const reportProgress = createSearchProgressReporter(extra, adapter.displayName);
343
431
  if (!hasProductContext(productContext)) {
344
432
  return jsonText({
345
433
  ok: false,
@@ -349,9 +437,40 @@ function createMcpServer() {
349
437
  }
350
438
  let fetched;
351
439
  try {
440
+ await reportProgress({
441
+ stage: "content_selected",
442
+ platform,
443
+ keyword: "单链接分析",
444
+ contentId: videoUrl,
445
+ contentUrl: videoUrl,
446
+ title: "",
447
+ authorName: "",
448
+ estimatedCommentCount: maxComments ?? 30,
449
+ });
352
450
  fetched = await (0, platform_runner_1.fetchContentComments)(platform, videoUrl, maxComments ?? 30);
451
+ await reportProgress({
452
+ stage: "content_read",
453
+ platform,
454
+ keyword: "单链接分析",
455
+ contentId: fetched.contentId,
456
+ contentUrl: fetched.contentUrl,
457
+ title: "",
458
+ status: "ok",
459
+ commentsFetched: fetched.comments.length,
460
+ });
353
461
  }
354
462
  catch (err) {
463
+ await reportProgress({
464
+ stage: "content_read",
465
+ platform,
466
+ keyword: "单链接分析",
467
+ contentId: videoUrl,
468
+ contentUrl: videoUrl,
469
+ title: "",
470
+ status: "failed",
471
+ commentsFetched: 0,
472
+ error: err instanceof Error ? err.message : String(err),
473
+ });
355
474
  if (err instanceof platform_runner_1.RunnerError) {
356
475
  return jsonText(runnerErrorText(err, platform));
357
476
  }
@@ -385,7 +504,7 @@ function createMcpServer() {
385
504
  if (reportLeads.length > 0) {
386
505
  const exported = (0, battle_report_1.exportBattleReport)({
387
506
  kind: "link",
388
- platformName: (0, registry_1.getPlatformAdapter)(platform).displayName,
507
+ platformName: adapter.displayName,
389
508
  productName: productContext.productName || (productContext.productId ? await productNameOf(productContext.productId) : undefined),
390
509
  contentUrl: fetched.contentUrl,
391
510
  summary: analysis.summary,
@@ -409,10 +528,39 @@ function createMcpServer() {
409
528
  const lockedTail = paywallLocked
410
529
  ? `你当前是体验版,只解锁了前 ${reportLeads.length} 个完整客户;${paywall.unlockHint}`
411
530
  : "";
531
+ const contentTrace = {
532
+ contentId: fetched.contentId,
533
+ url: fetched.contentUrl,
534
+ commentsFetched: fetched.comments.length,
535
+ commentsAnalyzed: analysis.summary.commentsAnalyzed,
536
+ demandsFound: analysis.summary.demandsFound,
537
+ highIntentCount: analysis.summary.highIntentCount,
538
+ status: "read",
539
+ };
540
+ const processNarrative = workflowTraceNarrative({
541
+ platformName: adapter.displayName,
542
+ itemsRead: 1,
543
+ commentsAnalyzed: analysis.summary.commentsAnalyzed,
544
+ demandsFound: analysis.summary.demandsFound,
545
+ contents: [contentTrace],
546
+ });
412
547
  return jsonText({
413
548
  ok: true,
414
549
  platform,
415
550
  content: { id: fetched.contentId, url: fetched.contentUrl },
551
+ workflowTrace: {
552
+ mode: "single_content",
553
+ platform,
554
+ contents: [contentTrace],
555
+ totals: {
556
+ itemsRead: 1,
557
+ commentsFetched: fetched.comments.length,
558
+ commentsAnalyzed: analysis.summary.commentsAnalyzed,
559
+ demandsFound: analysis.summary.demandsFound,
560
+ highIntentCount: analysis.summary.highIntentCount,
561
+ },
562
+ },
563
+ processNarrative,
416
564
  summary: {
417
565
  ...analysis.summary,
418
566
  commentsFetched: fetched.comments.length,
@@ -430,8 +578,8 @@ function createMcpServer() {
430
578
  ? {
431
579
  reportFile: report.file,
432
580
  reportHint: paywallLocked
433
- ? `已生成战报(网页文件),里面只展示了解锁的前 ${reportLeads.length} 个客户;其余已锁。开通套餐后,重新查询客户池或重新生成战报即可拿到完整结果。文件在:${report.file}`
434
- : `已生成一份客户战报(网页文件,含话术,可转发同事照着跟进),放在:${report.file}`,
581
+ ? `已生成本机临时战报备份,只展示已解锁的前 ${reportLeads.length} 个客户;它不是解锁入口。要看剩余线索,请登录 OPC 网页端客户池并开通/解锁完整名单。备份文件在:${report.file}`
582
+ : `已生成本机临时战报备份(含话术,可转发同事照着跟进),放在:${report.file}`,
435
583
  }
436
584
  : {}),
437
585
  });
@@ -480,7 +628,7 @@ function createMcpServer() {
480
628
  });
481
629
  registerTool("search_keyword_for_leads", {
482
630
  description: "关键词 + productId 或 productName + platform:搜内容 → 读评论 → AI 分析 → 汇总战报。未登录用户可只传 productName 先试跑,返回前 2 条线索;登录后传 productId 才会落客户池。支持 douyin/xiaohongshu/kuaishou。多关键词 2 窗口并行(抖音);小红书/快手更保守。" +
483
- "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
631
+ "工具会通过 MCP 通知实时汇报搜了哪些词、打开哪些内容、每条读了多少评论;最终结果也会返回 workflowTrace/processNarrative。reportFile 只是本机临时战报备份,不是解锁入口;用户要看剩余线索或保存完整名单时,应引导登录 OPC 网页端客户池。" +
484
632
  "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
485
633
  inputSchema: {
486
634
  platform: PLATFORM_ZOD.describe("在哪个平台搜:douyin | xiaohongshu | kuaishou。"),
@@ -504,7 +652,7 @@ function createMcpServer() {
504
652
  .describe("每个关键词最多读几条内容;合计有平台上限(抖音 12、小红书/快手 8)。"),
505
653
  save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
506
654
  },
507
- }, async (args) => {
655
+ }, async (args, extra) => {
508
656
  const platform = (0, detect_platform_1.normalizePlatformId)(args.platform);
509
657
  const keywords = args.keywords ?? [];
510
658
  const productContext = productContextFromArgs(args);
@@ -519,7 +667,7 @@ function createMcpServer() {
519
667
  }
520
668
  let outcomes;
521
669
  try {
522
- outcomes = await (0, platform_runner_1.searchKeywordsBatch)(platform, keywords, maxVideosPerKeyword ?? 5);
670
+ outcomes = await (0, platform_runner_1.searchKeywordsBatch)(platform, keywords, maxVideosPerKeyword ?? 5, 30, createSearchProgressReporter(extra, adapter.displayName));
523
671
  }
524
672
  catch (err) {
525
673
  if (err instanceof platform_runner_1.RunnerError) {
@@ -540,19 +688,48 @@ function createMcpServer() {
540
688
  let skippedDuplicates = 0;
541
689
  let saveFailedCount = 0;
542
690
  let itemsRead = 0;
691
+ let commentsFetched = 0;
543
692
  let lockedFromBackend = 0;
544
693
  let anyLocked = false;
545
694
  const perKeyword = [];
695
+ const workflowKeywords = [];
546
696
  for (const oc of outcomes) {
547
697
  if (!oc.ok || !oc.result) {
548
698
  perKeyword.push({ keyword: oc.keyword, ok: false, code: oc.errorCode });
699
+ workflowKeywords.push({
700
+ keyword: oc.keyword,
701
+ ok: false,
702
+ errorCode: oc.errorCode,
703
+ contents: [],
704
+ });
549
705
  continue;
550
706
  }
551
707
  let kwDemands = 0;
708
+ let kwCommentsAnalyzed = 0;
709
+ let kwHighIntentCount = 0;
552
710
  const contentItems = oc.result.items ?? oc.result.videos ?? [];
711
+ const contentTraceById = new Map();
712
+ for (const attempt of oc.result.readAttempts ?? []) {
713
+ contentTraceById.set(attempt.contentId, traceFromAttempt(attempt));
714
+ }
553
715
  for (const v of contentItems) {
554
716
  itemsRead += 1;
555
717
  const itemUrl = v.contentUrl ?? v.videoUrl ?? "";
718
+ commentsFetched += v.comments.length;
719
+ const contentTrace = contentTraceById.get(v.contentId) ??
720
+ {
721
+ contentId: v.contentId,
722
+ url: itemUrl,
723
+ title: v.title,
724
+ authorName: v.authorName,
725
+ estimatedCommentCount: v.commentCount,
726
+ commentsFetched: v.comments.length,
727
+ status: "read",
728
+ };
729
+ contentTrace.status = "read";
730
+ contentTrace.commentsFetched = v.comments.length;
731
+ contentTrace.url = itemUrl || contentTrace.url;
732
+ contentTraceById.set(v.contentId, contentTrace);
556
733
  try {
557
734
  const analysis = await (0, ppxc_client_1.analyzeComments)({
558
735
  ...productContext,
@@ -563,9 +740,14 @@ function createMcpServer() {
563
740
  save: save !== false,
564
741
  });
565
742
  commentsAnalyzed += analysis.summary.commentsAnalyzed;
743
+ kwCommentsAnalyzed += analysis.summary.commentsAnalyzed;
566
744
  demandsFound += analysis.summary.demandsFound;
567
745
  kwDemands += analysis.summary.demandsFound;
568
746
  highIntentCount += analysis.summary.highIntentCount;
747
+ kwHighIntentCount += analysis.summary.highIntentCount;
748
+ contentTrace.commentsAnalyzed = analysis.summary.commentsAnalyzed;
749
+ contentTrace.demandsFound = analysis.summary.demandsFound;
750
+ contentTrace.highIntentCount = analysis.summary.highIntentCount;
569
751
  savedToPool += analysis.saved;
570
752
  skippedDuplicates += analysis.skippedDuplicates ?? 0;
571
753
  if (analysis.saveFailed === true)
@@ -609,9 +791,24 @@ function createMcpServer() {
609
791
  perKeyword.push({
610
792
  keyword: oc.keyword,
611
793
  ok: true,
794
+ itemsFound: oc.result.itemsFound,
612
795
  itemsRead: contentItems.length,
796
+ commentsFetched: oc.result.totalComments,
797
+ commentsAnalyzed: kwCommentsAnalyzed,
613
798
  demands: kwDemands,
614
799
  });
800
+ workflowKeywords.push({
801
+ keyword: oc.keyword,
802
+ ok: true,
803
+ itemsFound: oc.result.itemsFound,
804
+ itemsSelected: oc.result.readAttempts?.length ?? contentItems.length,
805
+ itemsRead: contentItems.length,
806
+ commentsFetched: oc.result.totalComments,
807
+ commentsAnalyzed: kwCommentsAnalyzed,
808
+ demandsFound: kwDemands,
809
+ highIntentCount: kwHighIntentCount,
810
+ contents: [...contentTraceById.values()],
811
+ });
615
812
  }
616
813
  const hitVerification = outcomes.some((o) => !o.ok && o.errorCode === "VERIFICATION_REQUIRED");
617
814
  const hitLoginRequired = outcomes.some((o) => !o.ok && o.errorCode === "LOGIN_REQUIRED");
@@ -622,6 +819,7 @@ function createMcpServer() {
622
819
  code: "VERIFICATION_REQUIRED",
623
820
  userHint: `${adapter.displayName}要求验证,已弹出窗口,本次搜索已停止。请先完成验证,不要换词重试。`,
624
821
  perKeyword,
822
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
625
823
  });
626
824
  }
627
825
  if (hitLoginRequired) {
@@ -630,6 +828,7 @@ function createMcpServer() {
630
828
  code: "LOGIN_REQUIRED",
631
829
  userHint: `${adapter.displayName}登录已失效,请先登录后再重试。`,
632
830
  perKeyword,
831
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
633
832
  });
634
833
  }
635
834
  return jsonText({
@@ -637,6 +836,7 @@ function createMcpServer() {
637
836
  code: "EMPTY_COMMENTS",
638
837
  userHint: "搜到的内容没读到可分析的评论,换个词试试。",
639
838
  perKeyword,
839
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
640
840
  });
641
841
  }
642
842
  allLeads.sort((a, b) => (b.saleScore ?? 0) - (a.saleScore ?? 0));
@@ -678,14 +878,38 @@ function createMcpServer() {
678
878
  const lockedTail = paywallLocked
679
879
  ? `你当前是体验版,只解锁了前 ${visibleAllLeads.length} 个完整客户;${unlockHint}`
680
880
  : "";
881
+ const allContentTrace = workflowKeywords.flatMap((kw) => kw.contents);
882
+ const processNarrative = workflowTraceNarrative({
883
+ platformName: adapter.displayName,
884
+ keywords: outcomes.map((o) => o.keyword),
885
+ itemsRead,
886
+ commentsAnalyzed,
887
+ demandsFound,
888
+ contents: allContentTrace,
889
+ });
681
890
  return jsonText({
682
891
  ok: true,
683
892
  platform,
684
893
  keywords: outcomes.map((o) => o.keyword),
685
894
  warning,
895
+ workflowTrace: {
896
+ mode: "keyword_search",
897
+ platform,
898
+ keywords: workflowKeywords,
899
+ totals: {
900
+ keywordsSearched: outcomes.length,
901
+ itemsRead,
902
+ commentsFetched,
903
+ commentsAnalyzed,
904
+ demandsFound,
905
+ highIntentCount,
906
+ },
907
+ },
908
+ processNarrative,
686
909
  summary: {
687
910
  keywordsSearched: outcomes.length,
688
911
  itemsRead,
912
+ commentsFetched,
689
913
  commentsAnalyzed,
690
914
  demandsFound,
691
915
  highIntentCount,
@@ -704,19 +928,19 @@ function createMcpServer() {
704
928
  ? {
705
929
  reportFile: report.file,
706
930
  reportHint: paywallLocked
707
- ? `已生成战报(网页文件),只展示了解锁的前 ${visibleAllLeads.length} 个客户;其余已锁。开通套餐后,重新查询客户池或重新生成战报即可拿到完整结果。文件在:${report.file}`
708
- : `已生成一份客户战报(网页文件,含每个词的成绩和话术,可转发同事照着跟进),放在:${report.file}`,
931
+ ? `已生成本机临时战报备份,只展示已解锁的前 ${visibleAllLeads.length} 个客户;它不是解锁入口。要看剩余线索,请登录 OPC 网页端客户池并开通/解锁完整名单。备份文件在:${report.file}`
932
+ : `已生成本机临时战报备份(含每个词的成绩和话术,可转发同事照着跟进),放在:${report.file}`,
709
933
  }
710
934
  : {}),
711
935
  });
712
936
  });
713
937
  registerTool("suggest_search_keywords", {
714
- description: " productId,返回 OPC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。" +
938
+ description: "完整模式专用:给已保存产品的 productId,返回 OPC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。新用户/未登录试用/没有产品列表时不要调用本工具;应先向用户要产品/服务名称和 1-3 个朴素搜索词,或根据产品描述自己建议词,然后直接调用 search_keyword_for_leads 并传 productName/productDescription。" +
715
939
  "词单通常在产品创建/编辑时已自动生成,本工具默认直接读现成结果;没有现成词单时会触发一轮生成并等待(约 1 分钟)。" +
716
- "开搜前先调它,从主力词里挑 3~6 个传给 search_keyword_for_leads,比现场编词命中率高得多。" +
940
+ "只有用户已登录且明确选择了已保存产品时,才在开搜前调它,从主力词里挑 3~6 个传给 search_keyword_for_leads" +
717
941
  "regenerate=true 强制重新生成(消耗用户电力,仅当用户明确要求换一批词时使用)。",
718
942
  inputSchema: {
719
- productId: zod_1.z.string().describe("产品 id,用 list_products 获取。"),
943
+ productId: zod_1.z.string().describe("已登录完整模式的产品 id,用 list_products 获取;试用扫描不要为了拿 productId 要求用户登录或创建产品。"),
720
944
  regenerate: zod_1.z
721
945
  .boolean()
722
946
  .optional()
@@ -726,7 +950,11 @@ function createMcpServer() {
726
950
  const productId = args.productId;
727
951
  const regenerate = args.regenerate === true;
728
952
  if (!productId || typeof productId !== "string") {
729
- return jsonText({ ok: false, code: "NO_PRODUCT", userHint: "缺少 productId。请先用 list_products 选一个产品。" });
953
+ return jsonText({
954
+ ok: false,
955
+ code: "FULL_MODE_ONLY",
956
+ userHint: "想词委员会只用于已登录且已有产品的完整模式。新用户或试用扫描不要先建产品;请让用户给产品/服务名称和 1-3 个搜索词,然后直接调用 search_keyword_for_leads,传 productName/productDescription。",
957
+ });
730
958
  }
731
959
  try {
732
960
  let current = await (0, ppxc_client_1.getCommitteeKeywords)(productId);
@@ -777,7 +1005,7 @@ function createMcpServer() {
777
1005
  return jsonText({
778
1006
  ok: false,
779
1007
  code: "PPXC_LOGIN_REQUIRED",
780
- userHint: "OPC 账号未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
1008
+ userHint: "想词委员会需要 OPC 登录和已保存产品。若用户只是试用找客户,不要在这里卡住;请改为收集 productName/productDescription 和搜索词,直接调用 search_keyword_for_leads。只有用户明确要用已保存产品时,再登录 OPC。",
781
1009
  });
782
1010
  }
783
1011
  if (err instanceof ppxc_client_1.PpxcApiError && err.status === 403) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ppxc-leads-mcp",
3
3
  "productName": "Social Leads Signal MCP",
4
- "version": "0.1.11",
4
+ "version": "0.1.12",
5
5
  "description": "Detect high-intent customer signals from social comments",
6
6
  "keywords": [
7
7
  "mcp",
@@ -15,6 +15,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
15
15
  - 不要把它叫成「PPXC 后台」「PPXC 后端」「本机后台」。用户不需要、也不能自己启动一个 PPXC 后台。
16
16
  - MCP 工具不可用时,判断为「连接器没有启用 / MCP 配置没有生效 / 宿主还没重启」,不要说「后台没起来」。
17
17
  - 不要一上来要求用户登录 OPC。先让用户看到试用结果:用产品/服务描述 + 平台链接或关键词跑一次,展示前 2 条客户线索;用户要保存、看完整名单或解锁更多时,再引导登录 OPC。
18
+ - **已登录不等于要走产品列表。** 即使 `check_status_and_login` 显示 OPC 已登录、`list_products` 能列出旧产品,也不要自动使用旧产品;用户只是“测试一下 / 找客户 / 扫描评论”时,仍然先按试用路径收产品/服务描述并直接搜索。只有用户明确说「用我保存的产品」「查客户池」「保存完整名单」「继续跟进历史客户」时,才进入产品列表/客户池路径。
18
19
 
19
20
  ## 第 0 步:自检与自动接线(工具不可用时才走)
20
21
 
@@ -66,6 +67,13 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
66
67
 
67
68
  不要一上来调 `check_status_and_login(action=login_ppxc)`,也不要先调 `list_products`。OPC 登录只在用户要看剩余线索、保存完整名单或查询客户池时发生。
68
69
 
70
+ 禁止路径:
71
+
72
+ - 不要因为用户说“测试一下”就先登录 OPC。
73
+ - 不要因为用户已经登录就自动调用 `list_products`。
74
+ - 不要因为没有产品列表就要求用户注册、建产品或补后台资料。
75
+ - 不要为了调用 `suggest_search_keywords` 去要求 productId;试用阶段先用朴素搜索词跑出结果。
76
+
69
77
  ### 第 2 步:平台登录只在抓评论需要时处理
70
78
 
71
79
  试用扫描也需要借用户自己的平台登录态抓公开评论,但这不是 OPC 登录:
@@ -80,7 +88,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
80
88
  优先让用户先看到结果:
81
89
 
82
90
  - 未登录或用户只是试试看 → 不调 `list_products`。请用户给一句产品/服务描述,至少要有 `productName`,能补 `productDescription / sellingPoints / targetPersona` 更好。
83
- - 已登录且用户明确要用已保存产品 → 调 `list_products`。只有一个产品直接用;有多个时把名字列给用户选,**不要替用户猜**。
91
+ - 已登录且用户明确要用已保存产品/客户池 → 调 `list_products`。只有一个产品直接用;有多个时把名字列给用户选,**不要替用户猜**。
84
92
 
85
93
  ### 第 4 步:先要词,再开搜
86
94
 
@@ -98,7 +106,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
98
106
  - 已登录完整模式:传 `keywords + productId + platform`,结果会落客户池。
99
107
  - 未登录试用模式:传 `keywords + productName/productDescription + platform`,结果只展示前 2 条线索,不落客户池。
100
108
 
101
- 开搜前告诉用户:这一步要 2~3 分钟,会在后台用隐藏窗口干活。
109
+ 开搜前告诉用户:这一步要 2~3 分钟,会在后台用隐藏窗口干活。新版 MCP 会持续把进度事件发给智能体;如果宿主展示这些通知,要把“正在搜哪个词、打开了哪个链接、读到多少评论、哪条失败了”按事实转述给用户,不要只说“还在跑”。
102
110
 
103
111
  如果用户给的是具体的视频/笔记链接,跳过想词和搜索,直接调 `analyze_video_comments`:
104
112
 
@@ -110,11 +118,12 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
110
118
  按这个顺序说,不要把原始 JSON 念出来:
111
119
 
112
120
  1. **一句总结**:直接用返回里的 `summary.verdict`(已含首推客户及理由)。
113
- 2. **前 5 名**:每人一行——昵称、意向、需求类型、一句评论原话。
114
- 3. **战报文件**:如果返回里有 `reportFile`,务必告诉用户「完整战报(含可复制的跟进话术)已放到桌面:文件路径」,提醒可以转发给同事照着跟进。
115
- 4. **下一步提示**:提醒完整名单和历史记录在 OPC 网页端客户池。
116
- 5. **转化动作(重要)**:如果返回里 `paywall.locked` 为真,说明已经先给用户看到了前几个完整客户。要**如实、不啰嗦**地转达:「这次挖到 N 个,先给你看前 2 个完整线索(含话术和主页入口),其余 X 个已锁。登录/开通后可以保存到客户池并解锁完整名单」。用 `paywall.unlockHint` 的话术,别夸大、别假装全给了。
117
- 6. **登录时机**:用户说「看剩下的」「保存」「查客户池」「继续跟进」时,再调 `check_status_and_login`,参数 `action=login_ppxc`,让他登录 OPC。不要在试用扫描前做这一步。
121
+ 2. **过程透明**:如果返回里有 `processNarrative`,必须先用 2~5 行转述:搜了哪些词、实际看了哪些链接、每条读了多少评论、总共分析多少评论。不要用“应该/可能/还在跑”这种猜测词;只说工具返回的事实。需要更细时读取 `workflowTrace.keywords[].contents[]`。
122
+ 3. **前 5 名**:每人一行——昵称、意向、需求类型、一句评论原话。
123
+ 4. **战报文件**:如果返回里有 `reportFile`,只能把它说成「本机临时战报备份」,可用于转发同事照着跟进;不要把它说成解锁入口,也不要让用户误以为本地 HTML 能解锁全部客户。
124
+ 5. **下一步提示**:提醒完整名单、历史记录、解锁和保存动作在 OPC 网页端客户池完成。
125
+ 6. **转化动作(重要)**:如果返回里 `paywall.locked` 为真,说明已经先给用户看到了前几个完整客户。要**如实、不啰嗦**地转达:「这次挖到 N 个,先给你看前 2 个完整线索(含话术和主页入口),其余 X 个已锁。登录/开通后可以保存到客户池并解锁完整名单」。用 `paywall.unlockHint` 的话术,别夸大、别假装全给了。
126
+ 7. **登录时机**:用户说「看剩下的」「保存」「查客户池」「继续跟进」时,再调 `check_status_and_login`,参数 `action=login_ppxc`,让他登录 OPC。不要在试用扫描前做这一步。
118
127
 
119
128
  ### 第 6.1 步:收集用户判断(持续学习的关键)
120
129
 
@@ -190,4 +199,4 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
190
199
  > 2. Momo(高意向 · 竞品不满):“用了某大牌的防晒整张脸闷痘……”
191
200
  > 3. ……
192
201
  >
193
- > 完整战报已放到你桌面(含每个人的跟进话术,可直接复制):OPC客户战报-xxxx.html,可以转给同事照着跟进。全部 12 人已存入 OPC 客户池,网页端随时可看。
202
+ > 本机临时战报备份已放到你桌面(含可复制话术):OPC客户战报-xxxx.html,可以转给同事照着跟进。完整名单、解锁和历史记录在 OPC 网页端客户池里看。