ppxc-leads-mcp 0.1.10 → 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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Install the whole `skills/ppxc-find-customers/` folder into your assistant's ski
63
63
  ~/.codex/skills/ppxc-find-customers/
64
64
  ```
65
65
 
66
- MCP installation exposes the tools. Skill installation teaches the assistant the safe workflow: verify the connector and platform login, run a trial analysis from a product description, show the first customer signals, then ask the user to log in only when they want to save, unlock, or query the full customer pool.
66
+ MCP installation exposes the tools. Skill installation teaches the assistant the safe workflow: read the workflow manifest, ask for the product/service and target platform, run a trial analysis first, show the first two customer signals, then ask the user to log in only when they want to save, unlock, or query the full customer pool. OPC login is not required before a trial scan.
67
67
 
68
68
  ## Tools
69
69
 
@@ -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,8 +146,65 @@ 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
- LOGIN_REQUIRED: "还没登录。请先用 check_status_and_login 对应平台的登录动作扫码,再重试。",
207
+ LOGIN_REQUIRED: "平台还没登录。请用 check_status_and_login 对应平台的登录动作扫码,再重试。OPC 账号可等用户要解锁或保存完整名单时再登录。",
122
208
  VERIFICATION_REQUIRED: "平台要求验证,已弹出窗口。请完成验证后再重试,不要换词硬刷。",
123
209
  FETCH_TIMEOUT: "评论没拉回来,可能是网络或风控。稍等一下再试。",
124
210
  BAD_VIDEO_LINK: "链接无法识别,请确认是有效的内容链接。",
@@ -133,7 +219,7 @@ function runnerErrorText(err, platform) {
133
219
  const name = (0, registry_1.getPlatformAdapter)(platform).displayName;
134
220
  const hints = {
135
221
  ...RUNNER_HINTS,
136
- LOGIN_REQUIRED: `${name}还没登录。请先用 check_status_and_login 登录 ${name},再重试。`,
222
+ LOGIN_REQUIRED: `${name}还没登录。请先用 check_status_and_login 登录 ${name},再重试。OPC 账号可等用户要解锁或保存完整名单时再登录。`,
137
223
  VERIFICATION_REQUIRED: `${name}要求验证,已弹出窗口。请完成验证后再重试。`,
138
224
  BAD_VIDEO_LINK: `这个链接里没认出${name}内容,请确认链接正确。`,
139
225
  DAILY_LIMITED: `今天 ${name} 的安全抓取额度已用完,请明天再继续。`,
@@ -191,7 +277,7 @@ function createMcpServer() {
191
277
  });
192
278
  const registerTool = server.registerTool.bind(server);
193
279
  registerTool("check_status_and_login", {
194
- description: "检查 OPC 账号 + 抖音/小红书/快手登录态。action=login_douyin|login_xiaohongshu|login_kuaishou 弹对应平台扫码窗;action=login_ppxc 弹 OPC 账号登录窗;默认只查状态。",
280
+ description: "只做状态检查或显式登录。默认 action=status 只返回 OPC 与平台登录态,不弹窗;不要在试用扫描前调用 login_ppxc。用户要解锁、保存完整名单或查询客户池时,才用 action=login_ppxc 弹 OPC 登录窗;平台未登录导致无法抓评论时,才用 login_douyin|login_xiaohongshu|login_kuaishou 弹对应平台扫码窗。",
195
281
  inputSchema: {
196
282
  action: zod_1.z
197
283
  .enum([
@@ -202,7 +288,7 @@ function createMcpServer() {
202
288
  "login_ppxc",
203
289
  ])
204
290
  .optional()
205
- .describe("status=只查;login_*=弹对应平台登录窗;login_ppxc=OPC 账号登录。"),
291
+ .describe("status=只查不弹窗;login_douyin/login_xiaohongshu/login_kuaishou=平台扫码;login_ppxc=用户要解锁/保存/查客户池时才登录 OPC"),
206
292
  },
207
293
  }, async (args) => {
208
294
  const action = args.action ?? "status";
@@ -247,21 +333,24 @@ function createMcpServer() {
247
333
  }
248
334
  const platforms = await allPlatformLoginStatus();
249
335
  const ppxc = (0, ppxc_login_window_1.isPpxcLoggedIn)();
250
- const missing = [];
336
+ const missingPlatforms = [];
251
337
  for (const adapter of (0, registry_1.listPlatformAdapters)()) {
252
338
  if (!platforms[adapter.id]) {
253
- missing.push(`${adapter.displayName}(action=login_${adapter.id === "xiaohongshu" ? "xiaohongshu" : adapter.id})`);
339
+ missingPlatforms.push(`${adapter.displayName}(action=login_${adapter.id === "xiaohongshu" ? "xiaohongshu" : adapter.id})`);
254
340
  }
255
341
  }
256
- if (!ppxc)
257
- missing.push("OPC 账号(action=login_ppxc)");
258
342
  return jsonText({
259
343
  ok: true,
260
344
  platforms,
261
345
  ppxcLoggedIn: ppxc,
262
- hint: missing.length === 0
263
- ? "OPC 账号与各平台都已登录,可以开始找客户了。"
264
- : `还需要登录:${missing.join("、")}`,
346
+ trialScanAvailable: true,
347
+ fullPoolRequiresPpxcLogin: !ppxc,
348
+ missingPlatforms,
349
+ hint: missingPlatforms.length === 0
350
+ ? ppxc
351
+ ? "平台与 OPC 账号都已登录,可以完整找客户并保存到客户池。"
352
+ : "平台已登录,OPC 账号未登录也可以先试用扫描并展示前 2 条线索;用户要解锁、保存完整名单或查客户池时,再调用 action=login_ppxc。"
353
+ : `试用扫描不要求先登录 OPC,但抓评论需要对应平台登录:${missingPlatforms.join("、")}。请只弹平台扫码窗;不要先弹 OPC 登录窗。`,
265
354
  workflowManifest,
266
355
  logFile: logger_1.LOG_FILE_PATH,
267
356
  });
@@ -284,7 +373,7 @@ function createMcpServer() {
284
373
  }
285
374
  });
286
375
  registerTool("list_products", {
287
- description: "列出当前 OPC 账号下的产品,返回 id 和名称。分析评论前用它拿到 productId。",
376
+ description: "列出当前 OPC 账号下的产品,返回 id 和名称。仅当用户已登录且明确要用已保存产品/客户池时调用;未登录试用扫描不要调用本工具,直接把 productName/productDescription 传给 analyze_video_comments 或 search_keyword_for_leads。",
288
377
  inputSchema: {},
289
378
  }, async () => {
290
379
  try {
@@ -293,8 +382,8 @@ function createMcpServer() {
293
382
  ok: true,
294
383
  products,
295
384
  hint: products.length === 0
296
- ? "这个账号下还没有产品,请先到 OPC 网页端创建一个产品。"
297
- : "把其中一个产品的 id 作为 productId 传给 analyze_video_comments。",
385
+ ? "这个账号下还没有已保存产品。不要因此中断试用扫描;请让用户给产品/服务名称和搜索词,直接调用 search_keyword_for_leads 或 analyze_video_comments。只有用户要保存完整名单、查客户池或长期复盘时,再引导他到 OPC 创建产品。"
386
+ : "只有用户明确要使用已保存产品时,才把其中一个产品的 id 作为 productId;普通试用扫描仍可直接传 productName/productDescription。",
298
387
  });
299
388
  }
300
389
  catch (err) {
@@ -302,7 +391,7 @@ function createMcpServer() {
302
391
  return jsonText({
303
392
  ok: false,
304
393
  code: "PPXC_LOGIN_REQUIRED",
305
- userHint: "OPC 账号未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
394
+ userHint: "产品列表只用于已登录完整模式。若用户只是试用扫描,不要先登录 OPC;请收集 productName/productDescription 和搜索词,直接调用 search_keyword_for_leads。",
306
395
  });
307
396
  }
308
397
  return jsonText({
@@ -315,7 +404,7 @@ function createMcpServer() {
315
404
  });
316
405
  registerTool("analyze_video_comments", {
317
406
  description: "给一条抖音/小红书/快手内容链接 + productId 或 productName:读评论 → OPC AI 分析 → 客户战报。未登录用户可只传 productName 先试跑,返回前 2 条线索;登录后传 productId 才会落客户池。platform 可省略,会从链接自动识别。" +
318
- "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
407
+ "工具会通过 MCP 通知实时汇报进度,并在结果里返回 workflowTrace/processNarrative。reportFile 只是本机临时战报备份,不是解锁入口;用户要看剩余线索或保存完整名单时,应引导登录 OPC 网页端客户池。" +
319
408
  "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
320
409
  inputSchema: {
321
410
  videoUrl: zod_1.z.string().describe("内容链接(抖音视频/小红书笔记/快手视频)或 ID。"),
@@ -331,12 +420,14 @@ function createMcpServer() {
331
420
  maxComments: zod_1.z.number().int().min(1).max(50).optional().describe("最多读多少条评论,默认 30。"),
332
421
  save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
333
422
  },
334
- }, async (args) => {
423
+ }, async (args, extra) => {
335
424
  const videoUrl = args.videoUrl;
336
425
  const productContext = productContextFromArgs(args);
337
426
  const maxComments = args.maxComments;
338
427
  const save = args.save;
339
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);
340
431
  if (!hasProductContext(productContext)) {
341
432
  return jsonText({
342
433
  ok: false,
@@ -346,9 +437,40 @@ function createMcpServer() {
346
437
  }
347
438
  let fetched;
348
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
+ });
349
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
+ });
350
461
  }
351
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
+ });
352
474
  if (err instanceof platform_runner_1.RunnerError) {
353
475
  return jsonText(runnerErrorText(err, platform));
354
476
  }
@@ -382,7 +504,7 @@ function createMcpServer() {
382
504
  if (reportLeads.length > 0) {
383
505
  const exported = (0, battle_report_1.exportBattleReport)({
384
506
  kind: "link",
385
- platformName: (0, registry_1.getPlatformAdapter)(platform).displayName,
507
+ platformName: adapter.displayName,
386
508
  productName: productContext.productName || (productContext.productId ? await productNameOf(productContext.productId) : undefined),
387
509
  contentUrl: fetched.contentUrl,
388
510
  summary: analysis.summary,
@@ -406,10 +528,39 @@ function createMcpServer() {
406
528
  const lockedTail = paywallLocked
407
529
  ? `你当前是体验版,只解锁了前 ${reportLeads.length} 个完整客户;${paywall.unlockHint}`
408
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
+ });
409
547
  return jsonText({
410
548
  ok: true,
411
549
  platform,
412
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,
413
564
  summary: {
414
565
  ...analysis.summary,
415
566
  commentsFetched: fetched.comments.length,
@@ -427,8 +578,8 @@ function createMcpServer() {
427
578
  ? {
428
579
  reportFile: report.file,
429
580
  reportHint: paywallLocked
430
- ? `已生成战报(网页文件),里面只展示了解锁的前 ${reportLeads.length} 个客户;其余已锁。开通套餐后,重新查询客户池或重新生成战报即可拿到完整结果。文件在:${report.file}`
431
- : `已生成一份客户战报(网页文件,含话术,可转发同事照着跟进),放在:${report.file}`,
581
+ ? `已生成本机临时战报备份,只展示已解锁的前 ${reportLeads.length} 个客户;它不是解锁入口。要看剩余线索,请登录 OPC 网页端客户池并开通/解锁完整名单。备份文件在:${report.file}`
582
+ : `已生成本机临时战报备份(含话术,可转发同事照着跟进),放在:${report.file}`,
432
583
  }
433
584
  : {}),
434
585
  });
@@ -477,7 +628,7 @@ function createMcpServer() {
477
628
  });
478
629
  registerTool("search_keyword_for_leads", {
479
630
  description: "关键词 + productId 或 productName + platform:搜内容 → 读评论 → AI 分析 → 汇总战报。未登录用户可只传 productName 先试跑,返回前 2 条线索;登录后传 productId 才会落客户池。支持 douyin/xiaohongshu/kuaishou。多关键词 2 窗口并行(抖音);小红书/快手更保守。" +
480
- "挖到客户时会在用户桌面生成一份战报网页文件(结果里的 reportFile),汇报时务必告诉用户文件位置和首推客户及理由(topPick)。" +
631
+ "工具会通过 MCP 通知实时汇报搜了哪些词、打开哪些内容、每条读了多少评论;最终结果也会返回 workflowTrace/processNarrative。reportFile 只是本机临时战报备份,不是解锁入口;用户要看剩余线索或保存完整名单时,应引导登录 OPC 网页端客户池。" +
481
632
  "结果里的 contentAngles 是从这批评论提炼的内容选题方向(拍什么/为什么/客户原话),汇报后可主动提议「要不要根据这些评论写下一条视频脚本」。",
482
633
  inputSchema: {
483
634
  platform: PLATFORM_ZOD.describe("在哪个平台搜:douyin | xiaohongshu | kuaishou。"),
@@ -501,7 +652,7 @@ function createMcpServer() {
501
652
  .describe("每个关键词最多读几条内容;合计有平台上限(抖音 12、小红书/快手 8)。"),
502
653
  save: zod_1.z.boolean().optional().describe("是否落客户池,默认 true。"),
503
654
  },
504
- }, async (args) => {
655
+ }, async (args, extra) => {
505
656
  const platform = (0, detect_platform_1.normalizePlatformId)(args.platform);
506
657
  const keywords = args.keywords ?? [];
507
658
  const productContext = productContextFromArgs(args);
@@ -516,7 +667,7 @@ function createMcpServer() {
516
667
  }
517
668
  let outcomes;
518
669
  try {
519
- 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));
520
671
  }
521
672
  catch (err) {
522
673
  if (err instanceof platform_runner_1.RunnerError) {
@@ -537,19 +688,48 @@ function createMcpServer() {
537
688
  let skippedDuplicates = 0;
538
689
  let saveFailedCount = 0;
539
690
  let itemsRead = 0;
691
+ let commentsFetched = 0;
540
692
  let lockedFromBackend = 0;
541
693
  let anyLocked = false;
542
694
  const perKeyword = [];
695
+ const workflowKeywords = [];
543
696
  for (const oc of outcomes) {
544
697
  if (!oc.ok || !oc.result) {
545
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
+ });
546
705
  continue;
547
706
  }
548
707
  let kwDemands = 0;
708
+ let kwCommentsAnalyzed = 0;
709
+ let kwHighIntentCount = 0;
549
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
+ }
550
715
  for (const v of contentItems) {
551
716
  itemsRead += 1;
552
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);
553
733
  try {
554
734
  const analysis = await (0, ppxc_client_1.analyzeComments)({
555
735
  ...productContext,
@@ -560,9 +740,14 @@ function createMcpServer() {
560
740
  save: save !== false,
561
741
  });
562
742
  commentsAnalyzed += analysis.summary.commentsAnalyzed;
743
+ kwCommentsAnalyzed += analysis.summary.commentsAnalyzed;
563
744
  demandsFound += analysis.summary.demandsFound;
564
745
  kwDemands += analysis.summary.demandsFound;
565
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;
566
751
  savedToPool += analysis.saved;
567
752
  skippedDuplicates += analysis.skippedDuplicates ?? 0;
568
753
  if (analysis.saveFailed === true)
@@ -606,9 +791,24 @@ function createMcpServer() {
606
791
  perKeyword.push({
607
792
  keyword: oc.keyword,
608
793
  ok: true,
794
+ itemsFound: oc.result.itemsFound,
609
795
  itemsRead: contentItems.length,
796
+ commentsFetched: oc.result.totalComments,
797
+ commentsAnalyzed: kwCommentsAnalyzed,
610
798
  demands: kwDemands,
611
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
+ });
612
812
  }
613
813
  const hitVerification = outcomes.some((o) => !o.ok && o.errorCode === "VERIFICATION_REQUIRED");
614
814
  const hitLoginRequired = outcomes.some((o) => !o.ok && o.errorCode === "LOGIN_REQUIRED");
@@ -619,6 +819,7 @@ function createMcpServer() {
619
819
  code: "VERIFICATION_REQUIRED",
620
820
  userHint: `${adapter.displayName}要求验证,已弹出窗口,本次搜索已停止。请先完成验证,不要换词重试。`,
621
821
  perKeyword,
822
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
622
823
  });
623
824
  }
624
825
  if (hitLoginRequired) {
@@ -627,6 +828,7 @@ function createMcpServer() {
627
828
  code: "LOGIN_REQUIRED",
628
829
  userHint: `${adapter.displayName}登录已失效,请先登录后再重试。`,
629
830
  perKeyword,
831
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
630
832
  });
631
833
  }
632
834
  return jsonText({
@@ -634,6 +836,7 @@ function createMcpServer() {
634
836
  code: "EMPTY_COMMENTS",
635
837
  userHint: "搜到的内容没读到可分析的评论,换个词试试。",
636
838
  perKeyword,
839
+ workflowTrace: { mode: "keyword_search", platform, keywords: workflowKeywords },
637
840
  });
638
841
  }
639
842
  allLeads.sort((a, b) => (b.saleScore ?? 0) - (a.saleScore ?? 0));
@@ -675,14 +878,38 @@ function createMcpServer() {
675
878
  const lockedTail = paywallLocked
676
879
  ? `你当前是体验版,只解锁了前 ${visibleAllLeads.length} 个完整客户;${unlockHint}`
677
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
+ });
678
890
  return jsonText({
679
891
  ok: true,
680
892
  platform,
681
893
  keywords: outcomes.map((o) => o.keyword),
682
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,
683
909
  summary: {
684
910
  keywordsSearched: outcomes.length,
685
911
  itemsRead,
912
+ commentsFetched,
686
913
  commentsAnalyzed,
687
914
  demandsFound,
688
915
  highIntentCount,
@@ -701,19 +928,19 @@ function createMcpServer() {
701
928
  ? {
702
929
  reportFile: report.file,
703
930
  reportHint: paywallLocked
704
- ? `已生成战报(网页文件),只展示了解锁的前 ${visibleAllLeads.length} 个客户;其余已锁。开通套餐后,重新查询客户池或重新生成战报即可拿到完整结果。文件在:${report.file}`
705
- : `已生成一份客户战报(网页文件,含每个词的成绩和话术,可转发同事照着跟进),放在:${report.file}`,
931
+ ? `已生成本机临时战报备份,只展示已解锁的前 ${visibleAllLeads.length} 个客户;它不是解锁入口。要看剩余线索,请登录 OPC 网页端客户池并开通/解锁完整名单。备份文件在:${report.file}`
932
+ : `已生成本机临时战报备份(含每个词的成绩和话术,可转发同事照着跟进),放在:${report.file}`,
706
933
  }
707
934
  : {}),
708
935
  });
709
936
  });
710
937
  registerTool("suggest_search_keywords", {
711
- description: " productId,返回 OPC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。" +
938
+ description: "完整模式专用:给已保存产品的 productId,返回 OPC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。新用户/未登录试用/没有产品列表时不要调用本工具;应先向用户要产品/服务名称和 1-3 个朴素搜索词,或根据产品描述自己建议词,然后直接调用 search_keyword_for_leads 并传 productName/productDescription。" +
712
939
  "词单通常在产品创建/编辑时已自动生成,本工具默认直接读现成结果;没有现成词单时会触发一轮生成并等待(约 1 分钟)。" +
713
- "开搜前先调它,从主力词里挑 3~6 个传给 search_keyword_for_leads,比现场编词命中率高得多。" +
940
+ "只有用户已登录且明确选择了已保存产品时,才在开搜前调它,从主力词里挑 3~6 个传给 search_keyword_for_leads" +
714
941
  "regenerate=true 强制重新生成(消耗用户电力,仅当用户明确要求换一批词时使用)。",
715
942
  inputSchema: {
716
- productId: zod_1.z.string().describe("产品 id,用 list_products 获取。"),
943
+ productId: zod_1.z.string().describe("已登录完整模式的产品 id,用 list_products 获取;试用扫描不要为了拿 productId 要求用户登录或创建产品。"),
717
944
  regenerate: zod_1.z
718
945
  .boolean()
719
946
  .optional()
@@ -723,7 +950,11 @@ function createMcpServer() {
723
950
  const productId = args.productId;
724
951
  const regenerate = args.regenerate === true;
725
952
  if (!productId || typeof productId !== "string") {
726
- 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
+ });
727
958
  }
728
959
  try {
729
960
  let current = await (0, ppxc_client_1.getCommitteeKeywords)(productId);
@@ -774,7 +1005,7 @@ function createMcpServer() {
774
1005
  return jsonText({
775
1006
  ok: false,
776
1007
  code: "PPXC_LOGIN_REQUIRED",
777
- userHint: "OPC 账号未登录或登录失效,请先用 check_status_and_login(action=login_ppxc)登录。",
1008
+ userHint: "想词委员会需要 OPC 登录和已保存产品。若用户只是试用找客户,不要在这里卡住;请改为收集 productName/productDescription 和搜索词,直接调用 search_keyword_for_leads。只有用户明确要用已保存产品时,再登录 OPC。",
778
1009
  });
779
1010
  }
780
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.10",
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
 
@@ -40,15 +41,15 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
40
41
 
41
42
  3. **告诉用户**:「OPC 评论线索雷达的 MCP 配置已经加好了。首次启动时,智能体会按这条配置拉起 MCP 运行包(约一两分钟,取决于网络)。」
42
43
  4. **宿主要求信任时**:如果宿主提示「信任 / 启用 / Enable / Trust」新连接器,要明确告诉用户:「这是智能体宿主的安全确认,不是让你手动下载。请在连接器管理里信任/启用 `ppxc-leads` 或 `ppxc-find-customers`,点完回来告诉我,我继续试跑找客户。」不要把用户甩去自己研究配置。
43
- 5. **重启或信任后验证**:调 `check_status_and_login` 确认工具就位,然后从第 1 步继续。
44
+ 5. **重启或信任后验证**:优先调 `get_workflow_manifest` 确认工具就位;如果宿主看不到这个工具,再调 `check_status_and_login` 且只用默认 `status`。确认后从第 1 步继续,**不要**因此弹 OPC 登录窗。
44
45
  6. **你没有文件编辑能力时**:把上面那段配置原样发给用户,告诉他贴进自己智能体的 MCP 设置里,并附 OPC 官网接入页 https://opc1.me/download/mcp(有逐家图文步骤)。注意:这个页面只是接入说明,不是登录窗口。
45
46
 
46
- ## 动态工作流优先(每次开始都先做)
47
+ ## 动态工作流优先(每次开始都先做,但不要弹登录窗)
47
48
 
48
49
  本 Skill 不是完整业务逻辑的唯一来源。OPC 后端会持续进化找客户流程,所以每次开始找客户、复盘客户池或处理用户反馈前,必须先读一次当前动态工作流:
49
50
 
50
- 1. 优先调 `check_status_and_login`。它会返回 `workflowManifest`,里面包含当前能力清单、工作流版本、Skill 升级提示。
51
- 2. 如果宿主里能看到 `get_workflow_manifest`,也可以直接调用它读取最新作战手册。
51
+ 1. 优先调 `get_workflow_manifest` 读取最新作战手册。
52
+ 2. 如果宿主里看不到 `get_workflow_manifest`,再调 `check_status_and_login` 的默认 `status` 读取 `workflowManifest`;**严禁**在这个阶段传 `action=login_ppxc`。
52
53
  3. 如果动态工作流读取失败,不要中断找客户;继续按本文内置流程执行,并告诉用户“后端动态工作流暂时不可用,先用本地流程继续”。
53
54
  4. 如果返回里有 `skill.updateHint` 或 `skill.updateCommand`,在合适时机提醒用户:“OPC 评论线索雷达 Skill 有新版流程,可按官网或这条命令更新。”
54
55
 
@@ -56,22 +57,40 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
56
57
 
57
58
  ## 标准流程(按顺序)
58
59
 
59
- ### 第 1 步:确认工具和平台登录
60
+ ### 第 1 步:先收产品上下文,不先登录 OPC
60
61
 
61
- 先调 `check_status_and_login`(默认 status)确认 MCP 工具可用。这里不要因为 OPC 账号未登录就停下来。
62
+ 用户说“测试一下 / 帮我找客户 / 扫描评论 / 分析评论区”时,先拿试用扫描所需的最少信息:
62
63
 
63
- - 目标平台未登录 → 立刻调 `check_status_and_login`,参数 `action=login_douyin / login_xiaohongshu / login_kuaishou`,弹扫码窗,请用户用对应 App 扫码。
64
- - OPC 账号未登录 暂时不弹登录窗。先继续做试用分析,等用户看到线索、要保存或看完整名单时,再调 `action=login_ppxc`。
65
- - 如果弹出的窗口是“接入说明页”而不是登录表单,告诉用户这是配置地址误填或旧包问题:先关闭窗口,更新到新版 `ppxc-leads-mcp`,再重新调用 `action=login_ppxc`。
64
+ - 产品/服务:至少要有 `productName`,能补 `productDescription / sellingPoints / targetPersona` 更好。
65
+ - 平台:抖音 / 小红书 / 快手,用户没说就问一句。
66
+ - 入口:用户给了视频/笔记链接就直接分析链接;没给链接就要 1~3 个关键词,或根据产品描述先建议 1~3 个朴素搜索词给用户确认。
66
67
 
67
- ### 2 步:拿到产品上下文
68
+ 不要一上来调 `check_status_and_login(action=login_ppxc)`,也不要先调 `list_products`。OPC 登录只在用户要看剩余线索、保存完整名单或查询客户池时发生。
69
+
70
+ 禁止路径:
71
+
72
+ - 不要因为用户说“测试一下”就先登录 OPC。
73
+ - 不要因为用户已经登录就自动调用 `list_products`。
74
+ - 不要因为没有产品列表就要求用户注册、建产品或补后台资料。
75
+ - 不要为了调用 `suggest_search_keywords` 去要求 productId;试用阶段先用朴素搜索词跑出结果。
76
+
77
+ ### 第 2 步:平台登录只在抓评论需要时处理
78
+
79
+ 试用扫描也需要借用户自己的平台登录态抓公开评论,但这不是 OPC 登录:
80
+
81
+ - 先直接调 `search_keyword_for_leads` 或 `analyze_video_comments`,传 `productName/productDescription` 走未登录试用模式。
82
+ - 如果工具返回 `LOGIN_REQUIRED`,只针对对应平台调 `check_status_and_login`:`action=login_douyin / login_xiaohongshu / login_kuaishou`,请用户用对应 App 扫码。
83
+ - OPC 账号未登录 → 继续试用扫描,不弹 OPC 登录窗。
84
+ - 如果弹出的窗口是“接入说明页”而不是登录表单,告诉用户这是配置地址误填或旧包问题:先关闭窗口,更新到新版 `ppxc-leads-mcp`,再重新调用对应动作。
85
+
86
+ ### 第 3 步:已登录完整模式才拿产品
68
87
 
69
88
  优先让用户先看到结果:
70
89
 
71
90
  - 未登录或用户只是试试看 → 不调 `list_products`。请用户给一句产品/服务描述,至少要有 `productName`,能补 `productDescription / sellingPoints / targetPersona` 更好。
72
- - 已登录且用户明确要用已保存产品 → 调 `list_products`。只有一个产品直接用;有多个时把名字列给用户选,**不要替用户猜**。
91
+ - 已登录且用户明确要用已保存产品/客户池 → 调 `list_products`。只有一个产品直接用;有多个时把名字列给用户选,**不要替用户猜**。
73
92
 
74
- ### 第 3 步:先要词,再开搜
93
+ ### 第 4 步:先要词,再开搜
75
94
 
76
95
  不要在未登录试用阶段卡住用户:
77
96
 
@@ -80,32 +99,33 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
80
99
 
81
100
  `regenerate=true` 会重新生成并消耗用户电力——只有用户明确说「换一批词」才用。
82
101
 
83
- ### 第 4 步:开搜
102
+ ### 第 5 步:开搜
84
103
 
85
104
  调 `search_keyword_for_leads`。平台听用户的;用户没说就问一句,不要默认猜。
86
105
 
87
106
  - 已登录完整模式:传 `keywords + productId + platform`,结果会落客户池。
88
107
  - 未登录试用模式:传 `keywords + productName/productDescription + platform`,结果只展示前 2 条线索,不落客户池。
89
108
 
90
- 开搜前告诉用户:这一步要 2~3 分钟,会在后台用隐藏窗口干活。
109
+ 开搜前告诉用户:这一步要 2~3 分钟,会在后台用隐藏窗口干活。新版 MCP 会持续把进度事件发给智能体;如果宿主展示这些通知,要把“正在搜哪个词、打开了哪个链接、读到多少评论、哪条失败了”按事实转述给用户,不要只说“还在跑”。
91
110
 
92
111
  如果用户给的是具体的视频/笔记链接,跳过想词和搜索,直接调 `analyze_video_comments`:
93
112
 
94
113
  - 已登录完整模式:传 `videoUrl + productId`。
95
114
  - 未登录试用模式:传 `videoUrl + productName/productDescription`。
96
115
 
97
- ### 第 5 步:汇报成果(固定格式)
116
+ ### 第 6 步:汇报成果(固定格式)
98
117
 
99
118
  按这个顺序说,不要把原始 JSON 念出来:
100
119
 
101
120
  1. **一句总结**:直接用返回里的 `summary.verdict`(已含首推客户及理由)。
102
- 2. **前 5 名**:每人一行——昵称、意向、需求类型、一句评论原话。
103
- 3. **战报文件**:如果返回里有 `reportFile`,务必告诉用户「完整战报(含可复制的跟进话术)已放到桌面:文件路径」,提醒可以转发给同事照着跟进。
104
- 4. **下一步提示**:提醒完整名单和历史记录在 OPC 网页端客户池。
105
- 5. **转化动作(重要)**:如果返回里 `paywall.locked` 为真,说明已经先给用户看到了前几个完整客户。要**如实、不啰嗦**地转达:「这次挖到 N 个,先给你看前 2 个完整线索(含话术和主页入口),其余 X 个已锁。登录/开通后可以保存到客户池并解锁完整名单」。用 `paywall.unlockHint` 的话术,别夸大、别假装全给了。
106
- 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。不要在试用扫描前做这一步。
107
127
 
108
- ### 第 5.1 步:收集用户判断(持续学习的关键)
128
+ ### 第 6.1 步:收集用户判断(持续学习的关键)
109
129
 
110
130
  准不准不是系统说了算,是用户说了算。汇报完客户名单后,主动问一句:
111
131
 
@@ -121,7 +141,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
121
141
 
122
142
  用户反馈可以只针对 1 条,不要强迫他给整批打分。每次反馈都要带 `leadId`;如果当前汇报里没显示 id,就先用 `query_leads` 查出对应线索再标记。
123
143
 
124
- ### 第 5.2 步:记录跟进结果(成交闭环的关键)
144
+ ### 第 6.2 步:记录跟进结果(成交闭环的关键)
125
145
 
126
146
  系统不能保证成交,只能保证把可跟进机会识别、排序、提醒和复盘。真正是否成交,要靠用户跟进后回填。
127
147
 
@@ -134,7 +154,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
134
154
 
135
155
  更新后提醒用户:这些状态会进入后端学习和复盘,下一轮会更贴近他的真实客户。
136
156
 
137
- ### 第 5.5 步:内容彩蛋(挖到客户后主动提议)
157
+ ### 第 6.5 步:内容彩蛋(挖到客户后主动提议)
138
158
 
139
159
  每次找客户的返回里都带 `contentAngles`——从这批评论提炼的内容选题方向(每条含:拍什么角度、为什么、客户原话)。汇报完客户名单后,**主动加一句**:
140
160
 
@@ -149,7 +169,7 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
149
169
 
150
170
  这一步是「客户信号」到「内容获客」的飞轮:评论既识别了这批销售线索,又指明了下一条吸引同类客户的内容。**别强推**——用户不接就跳过。
151
171
 
152
- ### 第 6 步(隔天/复盘场景):查战果、换词
172
+ ### 第 7 步(隔天/复盘场景):查战果、换词
153
173
 
154
174
  用户问「之前挖到的客户怎么样了」「昨天那批有跟进吗」「哪些还没跟」时优先调 `review_followup_queue`,再按需要调 `query_leads` 看明细。
155
175
 
@@ -179,4 +199,4 @@ description: OPC 评论线索雷达:找客户、销售线索、评论分析、
179
199
  > 2. Momo(高意向 · 竞品不满):“用了某大牌的防晒整张脸闷痘……”
180
200
  > 3. ……
181
201
  >
182
- > 完整战报已放到你桌面(含每个人的跟进话术,可直接复制):OPC客户战报-xxxx.html,可以转给同事照着跟进。全部 12 人已存入 OPC 客户池,网页端随时可看。
202
+ > 本机临时战报备份已放到你桌面(含可复制话术):OPC客户战报-xxxx.html,可以转给同事照着跟进。完整名单、解锁和历史记录在 OPC 网页端客户池里看。