@gecho-ai/gecho-bridge 1.0.1 → 1.1.2

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
@@ -42,12 +42,52 @@ Add a new MCP server:
42
42
  - **Type**: command
43
43
  - **Command**: `npx -y @gecho-ai/gecho-bridge@latest`
44
44
 
45
+ ### Save Path Configuration
46
+ Search results are persisted to a JSON file. You can control the save directory in two ways:
47
+
48
+ 1. Pass `save_dir` in the MCP tool call arguments.
49
+ 2. Set environment variable `GECHO_DATA_DIR` for the MCP process.
50
+
51
+ Priority order used by the service:
52
+
53
+ 1. `save_dir` from request arguments
54
+ 2. `GECHO_DATA_DIR` environment variable
55
+ 3. Default `data` directory (`path.join(__dirname, "..", "data")`)
56
+
57
+ File naming:
58
+
59
+ - Result file pattern: `<query>_search_results.json`
60
+ - Query text is sanitized to a safe file name before writing.
61
+
62
+ Example (Claude Desktop with `GECHO_DATA_DIR`):
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "gecho-bridge": {
68
+ "command": "npx",
69
+ "args": ["-y", "@gecho-ai/gecho-bridge@latest"],
70
+ "env": {
71
+ "GECHO_DATA_DIR": "/Users/yourname/gecho-data"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Example (Cursor command with env):
79
+
80
+ ```bash
81
+ GECHO_DATA_DIR=/Users/yourname/gecho-data npx -y @gecho-ai/gecho-bridge@latest
82
+ ```
83
+
45
84
  ## Usage
46
85
 
47
86
  Once configured, you can ask your AI:
48
87
 
49
88
  - "Search TikTok for 'cooking recipes'"
50
89
  - "Find trending TikTok videos about AI agents"
90
+ - "Search TikTok for 'computer' and save to `/Users/yourname/data` (`save_dir`)"
51
91
 
52
92
  ## Development
53
93
 
package/index.js CHANGED
@@ -148,9 +148,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
148
  const timeoutId = setTimeout(() => {
149
149
  if (pendingRequests.has(requestId)) {
150
150
  pendingRequests.delete(requestId);
151
- resolve({ error: "抓取超时 (120s),请检查浏览器是否已停止滚动" });
151
+ resolve({ error: "抓取超时 (300s),请检查浏览器是否已停止滚动" });
152
152
  }
153
- }, 120000);
153
+ }, 300000);
154
154
 
155
155
  pendingRequests.set(requestId, { resolve, reject, timeoutId });
156
156
 
@@ -243,8 +243,8 @@ async function run() {
243
243
  const result = await new Promise((resolve) => {
244
244
  const timeoutId = setTimeout(() => {
245
245
  pendingRequests.delete(requestId);
246
- resolve({ error: "抓取超时 (120s)" });
247
- }, 120000);
246
+ resolve({ error: "抓取超时 (300s)" });
247
+ }, 300000);
248
248
 
249
249
  pendingRequests.set(requestId, { resolve, timeoutId });
250
250
 
package/mcp-client.js CHANGED
@@ -19,11 +19,14 @@ const http = require("http");
19
19
  const { spawn } = require("child_process");
20
20
  const path = require("path");
21
21
 
22
- const HTTP_SERVICE_URL = "http://127.0.0.1:18793/search";
22
+ const SERVICE_BASE_URL = "http://127.0.0.1:18793";
23
+ const HTTP_SERVICE_URL = `${SERVICE_BASE_URL}/search`;
24
+ const PING_URL = `${SERVICE_BASE_URL}/ping`;
25
+ const SHUTDOWN_URL = `${SERVICE_BASE_URL}/shutdown`;
23
26
  const SERVICE_PATH = path.join(__dirname, "server.js");
24
27
 
25
28
  const server = new Server(
26
- { name: "tiktok-bridge-client", version: "1.1.0" },
29
+ { name: "tiktok-bridge-client", version: "1.1.1" },
27
30
  { capabilities: { tools: {} } }
28
31
  );
29
32
 
@@ -32,7 +35,7 @@ const server = new Server(
32
35
  */
33
36
  function checkServiceAlive() {
34
37
  return new Promise((resolve) => {
35
- const req = http.get("http://127.0.0.1:18793/ping", (res) => {
38
+ const req = http.get(PING_URL, (res) => {
36
39
  resolve(res.statusCode === 200);
37
40
  });
38
41
  req.on("error", () => resolve(false));
@@ -41,35 +44,65 @@ function checkServiceAlive() {
41
44
  }
42
45
 
43
46
  /**
44
- * 自动拉起 Service 层
47
+ * 请求旧服务优雅退出
45
48
  */
46
- async function ensureServiceRunning() {
47
- const alive = await checkServiceAlive();
48
- if (alive) {
49
- console.error("🐷 Service Layer is already running.");
50
- return;
51
- }
49
+ function requestShutdown() {
50
+ return new Promise((resolve) => {
51
+ const req = http.request(SHUTDOWN_URL, { method: "POST" }, (res) => {
52
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
53
+ });
54
+ req.on("error", () => resolve(false));
55
+ req.setTimeout(1000, () => {
56
+ req.destroy();
57
+ resolve(false);
58
+ });
59
+ req.end();
60
+ });
61
+ }
52
62
 
53
- console.error("🐷 Service Layer not found. Starting it automatically...");
54
-
63
+ function startServiceDetached() {
55
64
  const child = spawn("node", [SERVICE_PATH], {
56
65
  detached: true,
57
66
  stdio: "ignore" // 静默启动,不占用当前终端
58
67
  });
59
-
60
68
  child.unref(); // 让子进程独立运行,父进程退出时不影响它
69
+ }
61
70
 
62
- // 等待服务启动成功
63
- let retries = 5;
71
+ async function waitForServiceDown() {
72
+ let retries = 10;
64
73
  while (retries > 0) {
65
- await new Promise(r => setTimeout(r, 1000));
74
+ await new Promise((r) => setTimeout(r, 300));
75
+ if (!(await checkServiceAlive())) {
76
+ return true;
77
+ }
78
+ retries--;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ async function waitForServiceUp() {
84
+ let retries = 8;
85
+ while (retries > 0) {
86
+ await new Promise((r) => setTimeout(r, 800));
66
87
  if (await checkServiceAlive()) {
67
- console.error("✅ Service Layer started successfully.");
68
- return;
88
+ return true;
69
89
  }
70
90
  retries--;
71
91
  }
72
- throw new Error("Failed to start Service Layer after 5s.");
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * 启动即接管:每次启动 MCP 时先尝试关闭旧服务,再拉起当前服务
97
+ */
98
+ async function ensureServiceRunning() {
99
+ await requestShutdown();
100
+ await waitForServiceDown();
101
+ startServiceDetached();
102
+ const ready = await waitForServiceUp();
103
+ if (!ready) {
104
+ throw new Error("Failed to start Service Layer.");
105
+ }
73
106
  }
74
107
 
75
108
  // 1. 定义工具
@@ -82,7 +115,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
82
115
  inputSchema: {
83
116
  type: "object",
84
117
  properties: {
85
- query: { type: "string", description: "搜索关键词 (例如: '猫薄荷')" }
118
+ query: { type: "string", description: "搜索关键词 (例如: '猫薄荷')" },
119
+ save_dir: { type: "string", description: "可选的保存目录绝对路径 (例如: '/Users/xxx/data')" }
86
120
  },
87
121
  required: ["query"]
88
122
  }
@@ -94,7 +128,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
94
128
  // 2. 转发工具请求到 Service 层
95
129
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
96
130
  if (request.params.name === "tiktok_search_top_200") {
97
- const { query } = request.params.arguments;
131
+ const { query, save_dir } = request.params.arguments;
98
132
 
99
133
  try {
100
134
  const requestService = () => new Promise((resolve, reject) => {
@@ -114,40 +148,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
114
148
  if (res.statusCode >= 400) {
115
149
  reject(new Error(parsed.error || `HTTP ${res.statusCode}`));
116
150
  } else {
117
- resolve(parsed.data);
151
+ resolve(parsed); // 返回整个响应对象
118
152
  }
119
153
  });
120
154
  });
121
155
 
122
156
  req.on("error", () => reject(new Error("Service Layer communication error")));
123
- req.write(JSON.stringify({ query }));
157
+ req.write(JSON.stringify({ query, save_dir }));
124
158
  req.end();
125
159
  });
126
160
 
127
- let result;
161
+ let serviceResponse;
128
162
  try {
129
- result = await requestService();
163
+ serviceResponse = await requestService();
130
164
  } catch (firstError) {
131
165
  if (String(firstError.message || "").includes("communication error")) {
132
166
  await ensureServiceRunning();
133
167
  await new Promise(r => setTimeout(r, 800));
134
- result = await requestService();
168
+ serviceResponse = await requestService();
135
169
  } else {
136
170
  throw firstError;
137
171
  }
138
172
  }
139
173
 
140
- if (result.error) {
141
- return { content: [{ type: "text", text: `❌ 错误: ${result.error}` }], isError: true };
174
+ if (serviceResponse.error) {
175
+ return { content: [{ type: "text", text: `❌ 错误: ${serviceResponse.error}` }], isError: true };
176
+ }
177
+
178
+ const result = serviceResponse.data;
179
+ if (typeof result === 'object' && result !== null && result.error) {
180
+ return { content: [{ type: "text", text: `❌ 抓取错误: ${result.error}` }], isError: true };
181
+ }
182
+
183
+ if (!Array.isArray(result)) {
184
+ return { content: [{ type: "text", text: `❌ 异常: 服务端未返回数组格式的数据` }], isError: true };
142
185
  }
143
186
 
144
- const top20 = Array.isArray(result) ? result.slice(0, 20) : [];
187
+ const savePath = serviceResponse.savePath || "";
188
+ const saveLine = savePath
189
+ ? `📂 完整结果已保存到: ${savePath}\n\n`
190
+ : `📂 未返回保存路径,请检查服务端日志。\n\n`;
191
+ const top20 = result.slice(0, 20);
145
192
  return {
146
193
  content: [
147
194
  {
148
195
  type: "text",
149
- text: `✅ 抓取完成!共获取 ${Array.isArray(result) ? result.length : 0} 条数据。\n` +
150
- `📂 完整结果已保存在本地 data 目录。\n\n` +
196
+ text: `✅ 抓取完成!共获取 ${result.length} 条数据。\n` +
197
+ saveLine +
151
198
  `以下是点赞最高的前 20 条结果:\n` +
152
199
  JSON.stringify(top20, null, 2)
153
200
  }
@@ -169,11 +216,9 @@ async function main() {
169
216
  await ensureServiceRunning();
170
217
  const transport = new StdioServerTransport();
171
218
  await server.connect(transport);
172
- console.error("🐷 TikTok Bridge MCP Client is running (STDIO)");
173
219
  } catch (e) {
174
- console.error(`❌ Client initialization failed: ${e.message}`);
175
220
  process.exit(1);
176
221
  }
177
222
  }
178
223
 
179
- main().catch(e => console.error(e));
224
+ main().catch(e => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gecho-ai/gecho-bridge",
3
- "version": "1.0.1",
3
+ "version": "1.1.2",
4
4
  "description": "MCP Bridge for TikTok Extension Relay - Connecting LLMs to TikTok via Chrome Extension",
5
5
  "main": "mcp-client.js",
6
6
  "bin": {
@@ -30,6 +30,6 @@
30
30
  "ws": "^8.19.0"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "public"
33
+ "access": "public"
34
34
  }
35
35
  }
package/server.js CHANGED
@@ -19,6 +19,81 @@ const HTTP_PORT = 18793;
19
19
  let extensionSocket = null;
20
20
  const pendingRequests = new Map();
21
21
  let requestIdCounter = 1;
22
+ let shuttingDown = false;
23
+
24
+ function decodeBase64Utf8(value) {
25
+ try {
26
+ return Buffer.from(String(value || ""), "base64").toString("utf8");
27
+ } catch (_e) {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ function normalizeQuery(rawQuery, queryB64) {
33
+ const queryText = String(rawQuery || "").trim();
34
+ const decodedFromB64 = decodeBase64Utf8(queryB64).trim();
35
+ if (decodedFromB64) {
36
+ if (!queryText || /^[??]+$/.test(queryText)) {
37
+ return decodedFromB64;
38
+ }
39
+ }
40
+ if (!queryText) {
41
+ return "";
42
+ }
43
+ try {
44
+ if (/%[0-9A-Fa-f]{2}/.test(queryText)) {
45
+ return decodeURIComponent(queryText);
46
+ }
47
+ } catch (_e) {}
48
+ return queryText;
49
+ }
50
+
51
+ function toSafeFileName(name) {
52
+ const value = String(name || "").trim();
53
+ const replaced = value
54
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
55
+ .replace(/[. ]+$/g, "")
56
+ .slice(0, 80);
57
+ const fallback = `query_${Date.now()}`;
58
+ const candidate = replaced || fallback;
59
+ const upper = candidate.toUpperCase();
60
+ const reserved = new Set([
61
+ "CON","PRN","AUX","NUL",
62
+ "COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
63
+ "LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"
64
+ ]);
65
+ return reserved.has(upper) ? `${candidate}_` : candidate;
66
+ }
67
+
68
+ function gracefulShutdown(reason) {
69
+ if (shuttingDown) return;
70
+ shuttingDown = true;
71
+ console.log(`🛑 Service shutting down: ${reason}`);
72
+
73
+ for (const [_requestId, pending] of pendingRequests) {
74
+ clearTimeout(pending.timeoutId);
75
+ pending.resolve({ error: "Service is shutting down" });
76
+ }
77
+ pendingRequests.clear();
78
+
79
+ try {
80
+ if (extensionSocket && extensionSocket.readyState === 1) {
81
+ extensionSocket.close(1001, "service_shutdown");
82
+ }
83
+ } catch (_e) {}
84
+
85
+ try {
86
+ wss.close(() => {
87
+ server.close(() => process.exit(0));
88
+ });
89
+ } catch (_e) {
90
+ try {
91
+ server.close(() => process.exit(0));
92
+ } catch (__e) {
93
+ process.exit(0);
94
+ }
95
+ }
96
+ }
22
97
 
23
98
  // --- WebSocket Server (与插件通信) ---
24
99
  const wss = new WebSocketServer({ port: WS_PORT, host: "127.0.0.1" });
@@ -61,19 +136,31 @@ wss.on("error", (err) => {
61
136
 
62
137
  // --- HTTP Server (供 Client 层调用) ---
63
138
  const server = http.createServer(async (req, res) => {
64
- res.setHeader("Content-Type", "application/json");
139
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
65
140
 
66
141
  // 健康检查接口
67
142
  if (req.method === "GET" && req.url === "/ping") {
68
143
  return res.end(JSON.stringify({ status: "ok" }));
69
144
  }
70
145
 
146
+ if (req.method === "POST" && req.url === "/shutdown") {
147
+ res.end(JSON.stringify({ status: "ok", message: "shutdown accepted" }));
148
+ setTimeout(() => gracefulShutdown("remote_shutdown"), 20).unref?.();
149
+ return;
150
+ }
151
+
71
152
  if (req.method === "POST" && req.url === "/search") {
153
+ if (shuttingDown) {
154
+ res.statusCode = 503;
155
+ return res.end(JSON.stringify({ error: "Service is shutting down" }));
156
+ }
157
+
72
158
  let body = "";
73
159
  req.on("data", chunk => { body += chunk; });
74
160
  req.on("end", async () => {
75
161
  try {
76
- const { query } = JSON.parse(body);
162
+ const payload = JSON.parse(body);
163
+ const query = normalizeQuery(payload.query, payload.query_b64);
77
164
  if (!query) {
78
165
  res.statusCode = 400;
79
166
  return res.end(JSON.stringify({ error: "Missing query" }));
@@ -90,8 +177,8 @@ const server = http.createServer(async (req, res) => {
90
177
  const result = await new Promise((resolve) => {
91
178
  const timeoutId = setTimeout(() => {
92
179
  pendingRequests.delete(requestId);
93
- resolve({ error: "Scraping timeout (120s)" });
94
- }, 120000);
180
+ resolve({ error: "Scraping timeout (300s)" });
181
+ }, 300000);
95
182
 
96
183
  pendingRequests.set(requestId, { resolve, timeoutId });
97
184
 
@@ -103,14 +190,22 @@ const server = http.createServer(async (req, res) => {
103
190
  });
104
191
 
105
192
  // 持久化存储
193
+ let savePath = "";
194
+ let saveWarning = "";
106
195
  if (Array.isArray(result) && result.length > 0) {
107
- const dataDir = path.join(__dirname, "..", "data");
196
+ // 优先级: 1. 请求中传来的 save_dir 2. 环境变量 GECHO_DATA_DIR 3. 默认 data 目录
197
+ const dataDir = payload.save_dir || process.env.GECHO_DATA_DIR || path.join(__dirname, "..", "data");
108
198
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
109
- const fixedPath = path.join(dataDir, `${query}_search_results.json`);
110
- fs.writeFileSync(fixedPath, JSON.stringify(result, null, 2), "utf8");
199
+ const safeName = toSafeFileName(query);
200
+ const fixedPath = path.join(dataDir, `${safeName}_search_results.json`);
201
+ try {
202
+ fs.writeFileSync(fixedPath, JSON.stringify(result, null, 2), "utf8");
203
+ savePath = fixedPath;
204
+ } catch (e) {
205
+ saveWarning = e.message;
206
+ }
111
207
  }
112
-
113
- res.end(JSON.stringify({ success: true, data: result }));
208
+ res.end(JSON.stringify({ success: true, data: result, savePath, saveWarning }));
114
209
  } catch (e) {
115
210
  res.statusCode = 500;
116
211
  res.end(JSON.stringify({ error: e.message }));
@@ -127,3 +222,6 @@ server.listen(HTTP_PORT, "127.0.0.1", () => {
127
222
  console.log(` - WebSocket (Extension): ws://127.0.0.1:${WS_PORT}`);
128
223
  console.log(` - HTTP API (Client): http://127.0.0.1:${HTTP_PORT}`);
129
224
  });
225
+
226
+ process.on("SIGTERM", () => gracefulShutdown("sigterm"));
227
+ process.on("SIGINT", () => gracefulShutdown("sigint"));