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.
- package/dist/browser/platform-runner.js +90 -4
- package/dist/mcp/server.js +246 -18
- package/package.json +1 -1
- package/skills/ppxc-find-customers/SKILL.md +17 -8
|
@@ -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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
? "
|
|
300
|
-
: "
|
|
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
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
?
|
|
434
|
-
:
|
|
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
|
-
"
|
|
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
|
-
?
|
|
708
|
-
:
|
|
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: "
|
|
938
|
+
description: "完整模式专用:给已保存产品的 productId,返回 OPC 想词委员会为该产品生成的精选搜索词(带词型分类和推荐理由)。新用户/未登录试用/没有产品列表时不要调用本工具;应先向用户要产品/服务名称和 1-3 个朴素搜索词,或根据产品描述自己建议词,然后直接调用 search_keyword_for_leads 并传 productName/productDescription。" +
|
|
715
939
|
"词单通常在产品创建/编辑时已自动生成,本工具默认直接读现成结果;没有现成词单时会触发一轮生成并等待(约 1 分钟)。" +
|
|
716
|
-
"
|
|
940
|
+
"只有用户已登录且明确选择了已保存产品时,才在开搜前调它,从主力词里挑 3~6 个传给 search_keyword_for_leads。" +
|
|
717
941
|
"regenerate=true 强制重新生成(消耗用户电力,仅当用户明确要求换一批词时使用)。",
|
|
718
942
|
inputSchema: {
|
|
719
|
-
productId: zod_1.z.string().describe("
|
|
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({
|
|
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
|
|
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
|
@@ -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
|
-
-
|
|
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.
|
|
114
|
-
3.
|
|
115
|
-
4.
|
|
116
|
-
5.
|
|
117
|
-
6.
|
|
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
|
-
>
|
|
202
|
+
> 本机临时战报备份已放到你桌面(含可复制话术):OPC客户战报-xxxx.html,可以转给同事照着跟进。完整名单、解锁和历史记录在 OPC 网页端客户池里看。
|