ppxc-leads-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +115 -0
  2. package/dist/backend/config.js +13 -0
  3. package/dist/backend/ppxc-client.js +156 -0
  4. package/dist/backend/ppxc-login-window.js +168 -0
  5. package/dist/backend/token-store.js +65 -0
  6. package/dist/browser/comments.js +9 -0
  7. package/dist/browser/douyin-runner.js +15 -0
  8. package/dist/browser/kernel/electron-profile.js +32 -0
  9. package/dist/browser/kernel/logger.js +57 -0
  10. package/dist/browser/kernel/page-scripts/index.js +1422 -0
  11. package/dist/browser/kernel/runner-page-manager.js +145 -0
  12. package/dist/browser/kernel/runner-page-session.js +1465 -0
  13. package/dist/browser/kernel/runner-page-session.search-parser.js +187 -0
  14. package/dist/browser/kernel/runner-page-session.user-agent.js +32 -0
  15. package/dist/browser/platform-runner.js +312 -0
  16. package/dist/browser/platforms/detect-platform.js +33 -0
  17. package/dist/browser/platforms/douyin/adapter.js +162 -0
  18. package/dist/browser/platforms/douyin/comments.js +130 -0
  19. package/dist/browser/platforms/kuaishou/adapter.js +178 -0
  20. package/dist/browser/platforms/kuaishou/comments.js +170 -0
  21. package/dist/browser/platforms/registry.js +23 -0
  22. package/dist/browser/platforms/shared/cdp-json-waiter.js +75 -0
  23. package/dist/browser/platforms/types.js +3 -0
  24. package/dist/browser/platforms/xiaohongshu/adapter.js +233 -0
  25. package/dist/browser/platforms/xiaohongshu/comments.js +184 -0
  26. package/dist/browser/usage-throttle.js +72 -0
  27. package/dist/main.js +64 -0
  28. package/dist/mcp/battle-report.js +325 -0
  29. package/dist/mcp/content-insights.js +66 -0
  30. package/dist/mcp/diagnostics.js +79 -0
  31. package/dist/mcp/server.js +829 -0
  32. package/dist/version.js +19 -0
  33. package/package.json +43 -0
  34. package/scripts/launch-mcp.cjs +96 -0
  35. package/skills/ppxc-find-customers/SKILL.md +110 -0
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # PPXC Leads MCP
2
+
3
+ 把 PPXC「从评论区发现高意向客户」的能力,做成智能体可直接调用的 MCP 工具包。
4
+
5
+ > **先读这两份,再动手:**
6
+ > 1. [`AGENTS.md`](./AGENTS.md) — 本仓库的硬性边界规则(AI 协作员必读第一文件)
7
+ > 2. [`docs/开发手册.md`](./docs/开发手册.md) — 架构、工具契约、平台适配层、验收标准
8
+ >
9
+ > 产品化(从本地半成品到正式可分发小组件)的路线:[`docs/产品化规划-2026-06-11.md`](./docs/产品化规划-2026-06-11.md)
10
+
11
+ ## 安装(一行配置)
12
+
13
+ 在支持 MCP 的智能体(Claude 桌面版 / Cursor / 其他)配置里加一段:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "ppxc-leads": {
19
+ "command": "npx",
20
+ "args": ["-y", "ppxc-leads-mcp"],
21
+ "env": {
22
+ "ELECTRON_MIRROR": "https://npmmirror.com/mirrors/electron/"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ - 首次使用会自动下载运行环境(约 100MB),国内网络保留上面的 `ELECTRON_MIRROR` 配置可显著提速。
30
+ - 默认连 PPXC 生产站;开发联调时在 `env` 里加 `PPXC_API_BASE` 指向本地后端。
31
+ - 装好后对智能体说「检查登录状态」,按提示完成 PPXC 登录和平台扫码即可使用。
32
+
33
+ ## 一句话定位
34
+
35
+ 用户在智能体里一行配置装好本工具包;首次登录 PPXC 账号 + 按需扫各平台二维码;之后对智能体说「分析这条内容 / 用这个词在小红书找客户」,工具包用自带的隐藏浏览器内核抓取数据、送 PPXC 后端分析,把「总体判断 + 高意向客户 + 跟进话术 + 内容选题」直接返回给智能体。
36
+
37
+ ## 支持的平台
38
+
39
+ | 平台 | 链接分析评论 | 关键词搜索 | 数据获取方式 |
40
+ |---|---|---|---|
41
+ | 抖音 | ✅ | ✅ | 页内 fetch + 搜索 sniffer(已真机验证) |
42
+ | 小红书 | ✅ 真机验证通过(2026-06-11) | ✅ 真机验证通过(2026-06-11) | CDP 监听 + 滚动;笔记页需带 xsec_token 签名链接 |
43
+ | 快手 | ✅ 真机验证通过(2026-06-11) | ✅ 真机验证通过(2026-06-11) | CDP 监听 graphql(rootCommentsV2);搜索走 /rest/v/search/feed |
44
+
45
+ ## 它不是什么
46
+
47
+ - **不是**桌面客户端:没有产品界面,唯一允许出现的窗口是登录窗和验证码窗
48
+ - **不是**浏览器插件:不依赖用户手动安装插件
49
+ - **不是**爬虫平台:借用户本人登录态、保守频率、撞验证就请真人处理,不做任何过验证 / 共享账号的灰色手段
50
+
51
+ ## 形态(方案 A:自带浏览器内核的 CDP MCP)
52
+
53
+ ```
54
+ 智能体(Claude / Cursor / ...)
55
+ │ MCP 协议(stdio)
56
+
57
+ 本工具包(无界面后台进程)
58
+ ├── MCP 协议层 ← 7 个 tool,platform 参数 / 链接自动识别
59
+ ├── 浏览器执行层 ← 平台插座 + 三平台方言件 + 隐藏窗口内核
60
+ └── 后端对接层 ← 调 PPXC 生产后端的同步分析接口
61
+ ```
62
+
63
+ ## 工具
64
+
65
+ | 工具 | 用户场景 | 状态 |
66
+ |---|---|---|
67
+ | `check_status_and_login` | 检查 PPXC + 抖音/小红书/快手登录;`login_*` 弹对应扫码窗 | 已接入三平台 |
68
+ | `list_products` | 列出账号下产品,拿 productId | 第二期 |
69
+ | `analyze_video_comments` | 给内容链接 + 产品 → 读评论 → AI 分析 → 战报 + 入池;platform 可省略 | 抖音已验;小红书/快手待真机 |
70
+ | `search_keyword_for_leads` | 给关键词 + **platform** → 搜内容 → 读评论 → 分析 → 汇总战报 | 抖音已验;小红书/快手待真机 |
71
+ | `suggest_search_keywords` | 开搜前先要词:读后端想词委员会为产品生成的精选搜索词(带词型+理由) | 0.2 新增 |
72
+ | `query_leads` | 问「之前挖到的客户 / 高意向有哪些」→ 只读查客户池,支持按词/天数/状态筛 | 0.2 新增 |
73
+ | `export_diagnostics` | 用户说「不好用 / 要反馈」→ 把运行日志打包成桌面上的诊断文件 | 已实测 |
74
+
75
+ ## 桌面战报(0.2)
76
+
77
+ 两件分析工具挖到客户后,会在用户桌面自动生成一份**客户战报**(自包含网页文件,离线可开):总览数字、行动清单(先跟谁 + 为什么)、客户卡片(评论原话 / 判断理由 / 一键复制话术 / 主页直达)、每个词的成绩单。可转发给同事直接照着跟进。
78
+
79
+ ## 官方技能说明书(教智能体怎么用)
80
+
81
+ [`skills/ppxc-find-customers/SKILL.md`](./skills/ppxc-find-customers/SKILL.md) 是给智能体的标准工作流:先查登录 → 选产品 → 要词 → 开搜 → 固定格式汇报(含战报文件位置)→ 隔天复盘换词,并写死了额度 / 验证码 / 电力的应对话术。装进 Claude / Cursor 等宿主的技能目录即可,npm 包里也随包分发。
82
+
83
+ ## 风控(按平台分日额度 + 全局 30 秒间隔)
84
+
85
+ 详见 [`docs/开发手册.md` §8](./docs/开发手册.md)。小红书/快手初版比抖音更保守。
86
+
87
+ ## 仓库关系(重要)
88
+
89
+ 本仓库**独立**,不与 `/Users/jianshi/ppxc`(网页 + 后端)、`/Users/jianshi/ppxc-desktop`(桌面客户端稳定线)共享代码、分支、git 历史。
90
+
91
+ - 后端:本仓库作为 PPXC 后端的**客户端**调用;分析接口 `POST /api/v2/comments/analyze` 在 `ppxc` 仓库,支持 `userProfileUrl` 按平台传入主页链接。
92
+ - 桌面客户端:从 `ppxc-desktop` 稳定线**单向搬运**执行内核,搬完解耦。
93
+
94
+ 详细边界见 [`AGENTS.md`](./AGENTS.md)。
95
+
96
+ ## 开发状态
97
+
98
+ ✅ **抖音三期主链路均已真机验证**(链接分析 + 关键词搜索 + 入池)。
99
+
100
+ 🟡 **多平台适配层(2026-06-11)**:
101
+
102
+ - 阶段 A:`platform-runner` + `platforms/*` 插座架构;抖音逻辑平移;`typecheck` + `build` + `regression:static` 通过。
103
+ - 阶段 B:**小红书真机验证通过**——扫码登录、关键词搜索(17 笔记/20 评论)、短链笔记拉评论(10 评论 + 主页链接)三链路实测 OK。探路结论:① 访客也有 web_session cookie,登录态必须读页面状态;② 笔记页必须带 xsec_token 签名链接,裸链接会被「暂时无法浏览」拦截;③ 短链跳转在慢网络下需轮询等待。MCP 工具层端到端(含后端分析+入池)待联调。
104
+ - 阶段 C:**快手真机验证通过**——扫码登录、关键词搜索(20 视频/18 评论)、单视频链接拉评论(13 评论 + 主页链接)三链路实测 OK。探路结论:① 扫码先发 passToken(id.kuaishou.com),主站会话票叫 `kuaishou.server.webday7_st`(带灰度后缀且 httpOnly),userId/did 都不能当登录凭证;② 搜索接口是 `/rest/v/search/feed`(作者在 feed 级、评论数字段不可靠);③ 视频页评论走 graphql `visionCommentList`,真数据在 `rootCommentsV2`(老字段是空壳)。MCP 工具层端到端(含后端分析+入池)待联调。
105
+ - 阶段 D:后端 `userProfileUrl` 优先使用 MCP 传入的主页链接;开发手册/README 已更新。
106
+
107
+ 验证命令:
108
+
109
+ ```bash
110
+ npm run regression:static # 静态回归(无需登录)
111
+ npm run smoke:comments # 抖音 smoke(需登录 + PPXC_MCP_VIDEO_URL)
112
+ PPXC_MCP_PLATFORM=xiaohongshu PPXC_MCP_LOGIN=1 npm run spike:probe # 小红书探路
113
+ ```
114
+
115
+ 运行与验证步骤:见 [`docs/第一期-运行与验证.md`](./docs/第一期-运行与验证.md)。
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getApiBase = getApiBase;
4
+ exports.isHttpUrl = isHttpUrl;
5
+ const PRODUCTION_API_BASE = "https://opc1.me";
6
+ function getApiBase() {
7
+ const raw = (process.env.PPXC_API_BASE || PRODUCTION_API_BASE).trim().replace(/\/+$/, "");
8
+ return raw;
9
+ }
10
+ function isHttpUrl(value) {
11
+ return /^https?:\/\//i.test(value);
12
+ }
13
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PpxcApiError = void 0;
4
+ exports.listProducts = listProducts;
5
+ exports.analyzeComments = analyzeComments;
6
+ exports.getCommitteeKeywords = getCommitteeKeywords;
7
+ exports.triggerCommitteeRun = triggerCommitteeRun;
8
+ exports.queryLeads = queryLeads;
9
+ const logger_1 = require("../browser/kernel/logger");
10
+ const version_1 = require("../version");
11
+ const config_1 = require("./config");
12
+ const token_store_1 = require("./token-store");
13
+ const log = logger_1.logger.scope("ppxc-client");
14
+ const LIST_TIMEOUT_MS = 15000;
15
+ const ANALYZE_TIMEOUT_MS = 180000;
16
+ class PpxcApiError extends Error {
17
+ constructor(status, message) {
18
+ super(message);
19
+ this.status = status;
20
+ this.name = "PpxcApiError";
21
+ }
22
+ }
23
+ exports.PpxcApiError = PpxcApiError;
24
+ function authHeaders() {
25
+ const token = (0, token_store_1.readToken)();
26
+ if (!token)
27
+ throw new PpxcApiError(401, "PPXC 未登录");
28
+ return {
29
+ Authorization: `Bearer ${token}`,
30
+ "Content-Type": "application/json",
31
+ "X-PPXC-MCP-Version": version_1.OWN_VERSION,
32
+ };
33
+ }
34
+ function handleUnauthorized() {
35
+ (0, token_store_1.clearToken)();
36
+ return new PpxcApiError(401, "PPXC 登录已失效");
37
+ }
38
+ async function fetchWithTimeout(url, init, timeoutMs) {
39
+ try {
40
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
41
+ }
42
+ catch (err) {
43
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
44
+ throw new PpxcApiError(0, `后端响应超时(${Math.round(timeoutMs / 1000)} 秒)`);
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+ async function listProducts() {
50
+ const res = await fetchWithTimeout(`${(0, config_1.getApiBase)()}/api/v2/products`, { method: "GET", headers: authHeaders() }, LIST_TIMEOUT_MS);
51
+ if (res.status === 401)
52
+ throw handleUnauthorized();
53
+ if (!res.ok)
54
+ throw new PpxcApiError(res.status, `获取产品列表失败 (${res.status})`);
55
+ const data = (await res.json());
56
+ const arr = Array.isArray(data) ? data : [];
57
+ return arr
58
+ .map((p) => {
59
+ const obj = p;
60
+ const id = String(obj.id ?? "").trim();
61
+ const name = String(obj.product_name ?? obj.productName ?? obj.name ?? "").trim();
62
+ return { id, name };
63
+ })
64
+ .filter((p) => p.id);
65
+ }
66
+ async function analyzeComments(input) {
67
+ const body = {
68
+ productId: input.productId,
69
+ videoUrl: input.videoUrl ?? "",
70
+ videoDesc: input.videoDesc ?? "",
71
+ save: input.save !== false,
72
+ comments: input.comments.map((c) => ({
73
+ text: c.text,
74
+ nickname: c.nickname,
75
+ userSecUid: c.userSecUid,
76
+ userProfileUrl: c.userProfileUrl,
77
+ avatarUrl: c.avatarUrl,
78
+ ipLabel: c.ipLabel,
79
+ createTime: c.createTime,
80
+ })),
81
+ };
82
+ const res = await fetchWithTimeout(`${(0, config_1.getApiBase)()}/api/v2/comments/analyze`, { method: "POST", headers: authHeaders(), body: JSON.stringify(body) }, ANALYZE_TIMEOUT_MS);
83
+ if (res.status === 401)
84
+ throw handleUnauthorized();
85
+ if (res.status === 403)
86
+ throw new PpxcApiError(403, "无权访问该产品");
87
+ if (!res.ok) {
88
+ let detail = `分析失败 (${res.status})`;
89
+ try {
90
+ const err = (await res.json());
91
+ if (err?.error)
92
+ detail = err.error;
93
+ }
94
+ catch {
95
+ }
96
+ throw new PpxcApiError(res.status, detail);
97
+ }
98
+ const data = (await res.json());
99
+ log.info("analyze done", {
100
+ analyzed: data.summary?.commentsAnalyzed,
101
+ demands: data.summary?.demandsFound,
102
+ saved: data.saved,
103
+ });
104
+ return data;
105
+ }
106
+ async function getCommitteeKeywords(productId) {
107
+ const url = `${(0, config_1.getApiBase)()}/api/v2/keywords/committee?productId=${encodeURIComponent(productId)}`;
108
+ const res = await fetchWithTimeout(url, { method: "GET", headers: authHeaders() }, LIST_TIMEOUT_MS);
109
+ if (res.status === 401)
110
+ throw handleUnauthorized();
111
+ if (res.status === 403)
112
+ throw new PpxcApiError(403, "无权访问该产品");
113
+ if (!res.ok)
114
+ throw new PpxcApiError(res.status, `读取搜词词单失败 (${res.status})`);
115
+ const data = (await res.json());
116
+ return {
117
+ generationStatus: data.generationStatus ?? "not_started",
118
+ errorMessage: data.errorMessage ?? "",
119
+ keywords: Array.isArray(data.keywords) ? data.keywords : [],
120
+ };
121
+ }
122
+ async function triggerCommitteeRun(productId) {
123
+ const res = await fetchWithTimeout(`${(0, config_1.getApiBase)()}/api/v2/keywords/committee/retry`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ productId }) }, LIST_TIMEOUT_MS);
124
+ if (res.status === 401)
125
+ throw handleUnauthorized();
126
+ if (res.status === 403)
127
+ throw new PpxcApiError(403, "无权访问该产品");
128
+ if (res.status === 503)
129
+ throw new PpxcApiError(503, "想词功能未对该产品开启");
130
+ if (!res.ok)
131
+ throw new PpxcApiError(res.status, `触发想词失败 (${res.status})`);
132
+ log.info("committee run triggered", { productId });
133
+ }
134
+ const LEADS_TIMEOUT_MS = 30000;
135
+ async function queryLeads(opts) {
136
+ const params = new URLSearchParams();
137
+ if (opts.productId)
138
+ params.set("productId", opts.productId);
139
+ if (opts.keyword)
140
+ params.set("keyword", opts.keyword);
141
+ if (opts.since)
142
+ params.set("since", opts.since);
143
+ const qs = params.toString();
144
+ const url = `${(0, config_1.getApiBase)()}/api/v2/leads${qs ? `?${qs}` : ""}`;
145
+ const res = await fetchWithTimeout(url, { method: "GET", headers: authHeaders() }, LEADS_TIMEOUT_MS);
146
+ if (res.status === 401)
147
+ throw handleUnauthorized();
148
+ if (res.status === 403)
149
+ throw new PpxcApiError(403, "无权访问该产品");
150
+ if (!res.ok)
151
+ throw new PpxcApiError(res.status, `查询客户池失败 (${res.status})`);
152
+ const data = (await res.json());
153
+ const paywallLocked = res.headers.get("x-ppxc-paywall") === "locked";
154
+ return { rows: Array.isArray(data) ? data : [], paywallLocked };
155
+ }
156
+ //# sourceMappingURL=ppxc-client.js.map
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPpxcLoggedIn = isPpxcLoggedIn;
4
+ exports.openPpxcLoginWindow = openPpxcLoginWindow;
5
+ exports.logoutPpxc = logoutPpxc;
6
+ const electron_1 = require("electron");
7
+ const logger_1 = require("../browser/kernel/logger");
8
+ const config_1 = require("./config");
9
+ const token_store_1 = require("./token-store");
10
+ const log = logger_1.logger.scope("ppxc-login");
11
+ const LOGIN_PARTITION = "persist:ppxc-mcp-login";
12
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
13
+ const EXCHANGE_DELAY_MS = 150;
14
+ let activeLoginWindow = null;
15
+ async function tryExchangeToken(win) {
16
+ if (win.isDestroyed())
17
+ return null;
18
+ try {
19
+ const script = `
20
+ (async () => {
21
+ try {
22
+ const r = await fetch('/api/auth/extension-token', { credentials: 'include' });
23
+ if (!r.ok) return { ok: false };
24
+ return (await r.json().catch(() => null)) || { ok: false };
25
+ } catch (e) { return { ok: false }; }
26
+ })()
27
+ `;
28
+ return (await win.webContents.executeJavaScript(script, true)) ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function isPpxcLoggedIn() {
35
+ return (0, token_store_1.readToken)() !== null;
36
+ }
37
+ async function openPpxcLoginWindow() {
38
+ const apiBase = (0, config_1.getApiBase)();
39
+ if (!(0, config_1.isHttpUrl)(apiBase)) {
40
+ return { ok: false, code: "api_base_invalid", message: "后端地址无效,请配置 PPXC_API_BASE" };
41
+ }
42
+ if (activeLoginWindow && !activeLoginWindow.isDestroyed()) {
43
+ activeLoginWindow.focus();
44
+ return { ok: false, code: "user_cancelled", message: "登录窗口已在打开中" };
45
+ }
46
+ const win = new electron_1.BrowserWindow({
47
+ title: "登录 PPXC",
48
+ width: 460,
49
+ height: 680,
50
+ show: false,
51
+ autoHideMenuBar: true,
52
+ minimizable: false,
53
+ maximizable: false,
54
+ webPreferences: {
55
+ contextIsolation: true,
56
+ nodeIntegration: false,
57
+ sandbox: true,
58
+ partition: LOGIN_PARTITION,
59
+ },
60
+ });
61
+ win.webContents.setAudioMuted(true);
62
+ activeLoginWindow = win;
63
+ const allowedOrigin = (() => {
64
+ try {
65
+ return new URL(apiBase).origin;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ })();
71
+ const isAllowedUrl = (url) => {
72
+ try {
73
+ return allowedOrigin !== null && new URL(url).origin === allowedOrigin;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ };
79
+ win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
80
+ const blockForeignNavigation = (event, url) => {
81
+ if (isAllowedUrl(url))
82
+ return;
83
+ event.preventDefault();
84
+ log.info("blocked foreign navigation in login window");
85
+ };
86
+ win.webContents.on("will-navigate", blockForeignNavigation);
87
+ win.webContents.on("will-redirect", blockForeignNavigation);
88
+ const loginUrl = `${apiBase}/login?redirect=${encodeURIComponent("/inbox")}&from=mcp`;
89
+ log.info("opening ppxc login window", { apiBase });
90
+ try {
91
+ await win.loadURL(loginUrl);
92
+ }
93
+ catch (err) {
94
+ log.warn("load login url failed", err instanceof Error ? err.message : String(err));
95
+ if (!win.isDestroyed())
96
+ win.destroy();
97
+ activeLoginWindow = null;
98
+ return { ok: false, code: "network_error", message: "无法打开登录页,请检查后端地址是否可达" };
99
+ }
100
+ win.show();
101
+ return new Promise((resolve) => {
102
+ let settled = false;
103
+ let timer = null;
104
+ let exchangePending = null;
105
+ const finish = (result) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ if (timer)
110
+ clearTimeout(timer);
111
+ if (exchangePending)
112
+ clearTimeout(exchangePending);
113
+ try {
114
+ win.removeAllListeners("closed");
115
+ if (!win.isDestroyed())
116
+ win.close();
117
+ }
118
+ catch {
119
+ }
120
+ activeLoginWindow = null;
121
+ resolve(result);
122
+ };
123
+ timer = setTimeout(() => finish({ ok: false, code: "timeout", message: "登录超时,请重试" }), LOGIN_TIMEOUT_MS);
124
+ win.on("closed", () => {
125
+ if (!settled)
126
+ finish({ ok: false, code: "user_cancelled", message: "登录窗口被关闭,没完成登录" });
127
+ });
128
+ const scheduleExchange = () => {
129
+ if (settled || win.isDestroyed())
130
+ return;
131
+ if (exchangePending)
132
+ clearTimeout(exchangePending);
133
+ exchangePending = setTimeout(async () => {
134
+ exchangePending = null;
135
+ if (settled || win.isDestroyed())
136
+ return;
137
+ const result = await tryExchangeToken(win);
138
+ if (!result || !result.ok || !result.extensionToken)
139
+ return;
140
+ (0, token_store_1.writeToken)(result.extensionToken);
141
+ const u = result.user || {};
142
+ log.info("ppxc login success", { userId: u.id ? String(u.id) : "?" });
143
+ finish({
144
+ ok: true,
145
+ user: {
146
+ id: String(u.id ?? ""),
147
+ email: String(u.email ?? ""),
148
+ name: typeof u.name === "string" ? u.name : null,
149
+ },
150
+ });
151
+ }, EXCHANGE_DELAY_MS);
152
+ };
153
+ win.webContents.on("did-navigate", scheduleExchange);
154
+ win.webContents.on("did-navigate-in-page", scheduleExchange);
155
+ win.webContents.on("did-finish-load", scheduleExchange);
156
+ scheduleExchange();
157
+ });
158
+ }
159
+ async function logoutPpxc() {
160
+ try {
161
+ const ses = electron_1.session.fromPartition(LOGIN_PARTITION);
162
+ await ses.clearStorageData({ storages: ["cookies", "localstorage"] });
163
+ }
164
+ catch (err) {
165
+ log.warn("clear login partition failed", err instanceof Error ? err.message : String(err));
166
+ }
167
+ }
168
+ //# sourceMappingURL=ppxc-login-window.js.map
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readToken = readToken;
7
+ exports.writeToken = writeToken;
8
+ exports.clearToken = clearToken;
9
+ const electron_1 = require("electron");
10
+ const node_fs_1 = require("node:fs");
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const logger_1 = require("../browser/kernel/logger");
13
+ const log = logger_1.logger.scope("token-store");
14
+ function tokenFilePath() {
15
+ return node_path_1.default.join(electron_1.app.getPath("userData"), "ppxc-token.json");
16
+ }
17
+ function jwtExpiryMs(token) {
18
+ try {
19
+ const payloadPart = token.split(".")[1];
20
+ if (!payloadPart)
21
+ return null;
22
+ const json = Buffer.from(payloadPart, "base64url").toString("utf8");
23
+ const payload = JSON.parse(json);
24
+ return typeof payload.exp === "number" ? payload.exp * 1000 : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function readToken() {
31
+ try {
32
+ const raw = (0, node_fs_1.readFileSync)(tokenFilePath(), "utf8");
33
+ const parsed = JSON.parse(raw);
34
+ const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
35
+ if (!token)
36
+ return null;
37
+ const expMs = jwtExpiryMs(token);
38
+ if (expMs !== null && expMs <= Date.now()) {
39
+ log.info("ppxc token expired, clearing");
40
+ clearToken();
41
+ return null;
42
+ }
43
+ return token;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function writeToken(token) {
50
+ try {
51
+ (0, node_fs_1.writeFileSync)(tokenFilePath(), JSON.stringify({ token }), { mode: 0o600 });
52
+ log.info("ppxc token saved");
53
+ }
54
+ catch (err) {
55
+ log.error("failed to save ppxc token", err instanceof Error ? err.message : String(err));
56
+ }
57
+ }
58
+ function clearToken() {
59
+ try {
60
+ (0, node_fs_1.rmSync)(tokenFilePath(), { force: true });
61
+ }
62
+ catch {
63
+ }
64
+ }
65
+ //# sourceMappingURL=token-store.js.map
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isDouyinShortLink = exports.extractAwemeId = exports.commentsFromJson = exports.buildCommentUrl = void 0;
4
+ var comments_1 = require("./platforms/douyin/comments");
5
+ Object.defineProperty(exports, "buildCommentUrl", { enumerable: true, get: function () { return comments_1.buildCommentUrl; } });
6
+ Object.defineProperty(exports, "commentsFromJson", { enumerable: true, get: function () { return comments_1.commentsFromJson; } });
7
+ Object.defineProperty(exports, "extractAwemeId", { enumerable: true, get: function () { return comments_1.extractAwemeId; } });
8
+ Object.defineProperty(exports, "isDouyinShortLink", { enumerable: true, get: function () { return comments_1.isDouyinShortLink; } });
9
+ //# sourceMappingURL=comments.js.map
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapSearchResultForDouyin = exports.shutdownRunner = exports.startPlatformLogin = exports.startDouyinLogin = exports.searchKeywordsBatch = exports.searchKeywordForLeads = exports.getLoginStatus = exports.fetchContentComments = exports.fetchVideoComments = exports.RunnerError = void 0;
4
+ var platform_runner_1 = require("./platform-runner");
5
+ Object.defineProperty(exports, "RunnerError", { enumerable: true, get: function () { return platform_runner_1.RunnerError; } });
6
+ Object.defineProperty(exports, "fetchVideoComments", { enumerable: true, get: function () { return platform_runner_1.fetchVideoComments; } });
7
+ Object.defineProperty(exports, "fetchContentComments", { enumerable: true, get: function () { return platform_runner_1.fetchContentComments; } });
8
+ Object.defineProperty(exports, "getLoginStatus", { enumerable: true, get: function () { return platform_runner_1.getLoginStatus; } });
9
+ Object.defineProperty(exports, "searchKeywordForLeads", { enumerable: true, get: function () { return platform_runner_1.searchKeywordForLeads; } });
10
+ Object.defineProperty(exports, "searchKeywordsBatch", { enumerable: true, get: function () { return platform_runner_1.searchKeywordsBatch; } });
11
+ Object.defineProperty(exports, "startDouyinLogin", { enumerable: true, get: function () { return platform_runner_1.startDouyinLogin; } });
12
+ Object.defineProperty(exports, "startPlatformLogin", { enumerable: true, get: function () { return platform_runner_1.startPlatformLogin; } });
13
+ Object.defineProperty(exports, "shutdownRunner", { enumerable: true, get: function () { return platform_runner_1.shutdownRunner; } });
14
+ Object.defineProperty(exports, "mapSearchResultForDouyin", { enumerable: true, get: function () { return platform_runner_1.mapSearchResultForDouyin; } });
15
+ //# sourceMappingURL=douyin-runner.js.map
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_DESKTOP_PROFILE_NAME = void 0;
7
+ exports.configureDesktopElectronProfile = configureDesktopElectronProfile;
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ exports.DEFAULT_DESKTOP_PROFILE_NAME = "OPC1 Desktop";
10
+ function resolveProfileName(options) {
11
+ const envName = process.env.OPC1_ELECTRON_PROFILE_NAME?.trim();
12
+ return (options?.profileName || envName || exports.DEFAULT_DESKTOP_PROFILE_NAME).trim();
13
+ }
14
+ function resolveUserDataDir(app, profileName, options) {
15
+ const envDir = process.env.OPC1_ELECTRON_USER_DATA_DIR?.trim();
16
+ const explicit = options?.userDataDir || envDir;
17
+ if (explicit && explicit.trim())
18
+ return explicit.trim();
19
+ return node_path_1.default.join(app.getPath("appData"), profileName);
20
+ }
21
+ function configureDesktopElectronProfile(app, options) {
22
+ const profileName = resolveProfileName(options);
23
+ const userDataDir = resolveUserDataDir(app, profileName, options);
24
+ app.setName(profileName);
25
+ app.setPath("userData", userDataDir);
26
+ if (options?.logUserDataPathOnce) {
27
+ const log = options.log || ((msg) => console.log(msg));
28
+ log(`[opc1] electron userData = ${app.getPath("userData")}`);
29
+ }
30
+ return { profileName, userDataDir };
31
+ }
32
+ //# sourceMappingURL=electron-profile.js.map
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LOG_FILE_PATH = exports.logger = void 0;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_os_1 = require("node:os");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ function resolveLogFile() {
11
+ const dir = process.env.PPXC_MCP_LOG_DIR?.trim() || node_path_1.default.join((0, node_os_1.tmpdir)(), "ppxc-mcp-logs");
12
+ try {
13
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
14
+ }
15
+ catch {
16
+ }
17
+ return node_path_1.default.join(dir, "ppxc-mcp.log");
18
+ }
19
+ const LOG_FILE = resolveLogFile();
20
+ function formatMessage(level, tag, ...args) {
21
+ const ts = new Date().toISOString();
22
+ const body = args
23
+ .map((arg) => {
24
+ if (typeof arg === "string")
25
+ return arg;
26
+ try {
27
+ return JSON.stringify(arg);
28
+ }
29
+ catch {
30
+ return String(arg);
31
+ }
32
+ })
33
+ .join(" ");
34
+ return `[${ts}] [${level.toUpperCase()}] [${tag}] ${body}`;
35
+ }
36
+ function writeLine(line) {
37
+ try {
38
+ (0, node_fs_1.appendFileSync)(LOG_FILE, line + "\n");
39
+ }
40
+ catch {
41
+ }
42
+ }
43
+ function createScopedLogger(tag) {
44
+ return {
45
+ info: (...args) => writeLine(formatMessage("info", tag, ...args)),
46
+ warn: (...args) => writeLine(formatMessage("warn", tag, ...args)),
47
+ error: (...args) => writeLine(formatMessage("error", tag, ...args)),
48
+ };
49
+ }
50
+ exports.logger = {
51
+ info: (...args) => writeLine(formatMessage("info", "app", ...args)),
52
+ warn: (...args) => writeLine(formatMessage("warn", "app", ...args)),
53
+ error: (...args) => writeLine(formatMessage("error", "app", ...args)),
54
+ scope: createScopedLogger,
55
+ };
56
+ exports.LOG_FILE_PATH = LOG_FILE;
57
+ //# sourceMappingURL=logger.js.map