@lightcone-ai/daemon 0.15.17 → 0.15.18
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/mcp-servers/wechat-mp-fetch/index.js +470 -0
- package/mcp-servers/wechat-mp-fetch/lib/core.js +166 -0
- package/mcp-servers/wechat-mp-fetch/manifest.json +17 -0
- package/mcp-servers/wechat-mp-fetch/package.json +11 -0
- package/package.json +1 -1
- package/src/agent-manager.js +1 -0
- package/src/mcp-config.js +2 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { accessSync, constants as fsConstants } from 'fs';
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import net from 'net';
|
|
7
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { WebSocket } from 'ws';
|
|
11
|
+
import {
|
|
12
|
+
WECHAT_MP_ORIGIN,
|
|
13
|
+
assertWechatArticleUrl,
|
|
14
|
+
clampInt,
|
|
15
|
+
normalizeAppmsgPublishResponse,
|
|
16
|
+
normalizeSearchbizResponse,
|
|
17
|
+
parseArticleHtml,
|
|
18
|
+
parseJsonMaybe,
|
|
19
|
+
} from './lib/core.js';
|
|
20
|
+
|
|
21
|
+
const WECHAT_MP_PROFILE_DIR = process.env.WECHAT_MP_PROFILE_DIR ?? '';
|
|
22
|
+
const SERVER_URL = process.env.SERVER_URL ?? '';
|
|
23
|
+
const MACHINE_API_KEY = process.env.MACHINE_API_KEY ?? '';
|
|
24
|
+
const AGENT_ID = process.env.AGENT_ID ?? '';
|
|
25
|
+
const GOVERNANCE_SPAWN_BUNDLE_ID = process.env.GOVERNANCE_SPAWN_BUNDLE_ID ?? '';
|
|
26
|
+
const GOVERNANCE_POLICY_VERSION = process.env.GOVERNANCE_POLICY_VERSION ?? '';
|
|
27
|
+
const GOVERNANCE_POLICY_LEASE = parseJsonMaybe(process.env.GOVERNANCE_POLICY_LEASE ?? '');
|
|
28
|
+
const REQUEST_TIMEOUT_MS = clampInt(process.env.WECHAT_MP_FETCH_TIMEOUT_MS, 3000, 60000, 12000);
|
|
29
|
+
const MIN_REQUEST_INTERVAL_MS = clampInt(process.env.WECHAT_MP_MIN_INTERVAL_MS, 1000, 60000, 5000);
|
|
30
|
+
const CACHE_TTL_MS = clampInt(process.env.WECHAT_MP_CACHE_TTL_MS, 5000, 3600000, 300000);
|
|
31
|
+
const FINGERPRINT = String(process.env.WECHAT_MP_FINGERPRINT ?? '').trim();
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
let lastRequestAt = 0;
|
|
36
|
+
let session = null;
|
|
37
|
+
|
|
38
|
+
function detectChrome() {
|
|
39
|
+
if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
|
|
40
|
+
const candidates = [
|
|
41
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
42
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
43
|
+
'/usr/bin/google-chrome',
|
|
44
|
+
'/usr/bin/google-chrome-stable',
|
|
45
|
+
'/usr/bin/chromium-browser',
|
|
46
|
+
'/usr/bin/chromium',
|
|
47
|
+
'/snap/bin/chromium',
|
|
48
|
+
];
|
|
49
|
+
for (const p of candidates) {
|
|
50
|
+
try { accessSync(p, fsConstants.X_OK); return p; } catch {}
|
|
51
|
+
}
|
|
52
|
+
return candidates[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getFreePort() {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const srv = net.createServer();
|
|
58
|
+
srv.listen(0, () => {
|
|
59
|
+
const port = srv.address().port;
|
|
60
|
+
srv.close(() => resolve(port));
|
|
61
|
+
});
|
|
62
|
+
srv.on('error', reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function httpGet(url) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
http.get(url, (res) => {
|
|
69
|
+
let body = '';
|
|
70
|
+
res.on('data', (d) => { body += d; });
|
|
71
|
+
res.on('end', () => resolve(body));
|
|
72
|
+
}).on('error', reject);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class CdpSession {
|
|
77
|
+
constructor() {
|
|
78
|
+
this._proc = null;
|
|
79
|
+
this._ws = null;
|
|
80
|
+
this._nextId = 1;
|
|
81
|
+
this._pending = new Map();
|
|
82
|
+
this._port = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async launch(profileDir) {
|
|
86
|
+
if (!profileDir) throw new Error('LOGIN_REQUIRED:WECHAT_MP_PROFILE_DIR is not configured');
|
|
87
|
+
const chrome = detectChrome();
|
|
88
|
+
this._port = await getFreePort();
|
|
89
|
+
this._proc = spawn(chrome, [
|
|
90
|
+
`--remote-debugging-port=${this._port}`,
|
|
91
|
+
'--no-sandbox',
|
|
92
|
+
'--disable-dev-shm-usage',
|
|
93
|
+
'--headless=new',
|
|
94
|
+
'--disable-blink-features=AutomationControlled',
|
|
95
|
+
'--disable-infobars',
|
|
96
|
+
'--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
97
|
+
`--user-data-dir=${profileDir}`,
|
|
98
|
+
'--window-size=1280,900',
|
|
99
|
+
`${WECHAT_MP_ORIGIN}/cgi-bin/home?t=home/index&lang=zh_CN`,
|
|
100
|
+
], { stdio: 'ignore' });
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < 50; i += 1) {
|
|
103
|
+
await sleep(300);
|
|
104
|
+
try { await httpGet(`http://localhost:${this._port}/json/version`); break; } catch {}
|
|
105
|
+
if (i === 49) throw new Error('LOGIN_REQUIRED:Chrome did not start');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pages = JSON.parse(await httpGet(`http://localhost:${this._port}/json`));
|
|
109
|
+
const page = pages.find((p) => p.type === 'page') ?? pages[0];
|
|
110
|
+
if (!page?.webSocketDebuggerUrl) throw new Error('LOGIN_REQUIRED:no Chrome page found');
|
|
111
|
+
await this._connectWs(page.webSocketDebuggerUrl);
|
|
112
|
+
await this.cdp('Page.enable');
|
|
113
|
+
await this.cdp('Network.enable');
|
|
114
|
+
await this.cdp('Runtime.enable');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_connectWs(wsUrl) {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const ws = new WebSocket(wsUrl);
|
|
120
|
+
ws.on('open', () => { this._ws = ws; resolve(); });
|
|
121
|
+
ws.on('message', (data) => {
|
|
122
|
+
let msg = null;
|
|
123
|
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
124
|
+
if (msg.id == null) return;
|
|
125
|
+
const pending = this._pending.get(msg.id);
|
|
126
|
+
if (!pending) return;
|
|
127
|
+
clearTimeout(pending.timer);
|
|
128
|
+
this._pending.delete(msg.id);
|
|
129
|
+
if (msg.error) pending.reject(new Error(msg.error.message ?? 'CDP error'));
|
|
130
|
+
else pending.resolve(msg.result);
|
|
131
|
+
});
|
|
132
|
+
ws.on('error', reject);
|
|
133
|
+
ws.on('close', () => {
|
|
134
|
+
for (const [, pending] of this._pending) {
|
|
135
|
+
clearTimeout(pending.timer);
|
|
136
|
+
pending.reject(new Error('CDP websocket closed'));
|
|
137
|
+
}
|
|
138
|
+
this._pending.clear();
|
|
139
|
+
this._ws = null;
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cdp(method, params = {}, timeoutMs = 30000) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
if (!this._ws) return reject(new Error('CDP session is not connected'));
|
|
147
|
+
const id = this._nextId;
|
|
148
|
+
this._nextId += 1;
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this._pending.delete(id);
|
|
151
|
+
reject(new Error(`CDP timeout:${method}`));
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
154
|
+
this._ws.send(JSON.stringify({ id, method, params }));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async navigate(url) {
|
|
159
|
+
await this.cdp('Page.navigate', { url });
|
|
160
|
+
await sleep(1500);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async evalValue(expression) {
|
|
164
|
+
const result = await this.cdp('Runtime.evaluate', {
|
|
165
|
+
expression,
|
|
166
|
+
awaitPromise: true,
|
|
167
|
+
returnByValue: true,
|
|
168
|
+
}, REQUEST_TIMEOUT_MS + 5000);
|
|
169
|
+
if (result?.exceptionDetails) {
|
|
170
|
+
throw new Error(result.exceptionDetails.text ?? 'Runtime.evaluate failed');
|
|
171
|
+
}
|
|
172
|
+
return result?.result?.value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async currentUrl() {
|
|
176
|
+
return this.evalValue('location.href');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getSession() {
|
|
181
|
+
if (session) return session;
|
|
182
|
+
session = new CdpSession();
|
|
183
|
+
await session.launch(WECHAT_MP_PROFILE_DIR);
|
|
184
|
+
return session;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function ensureLoggedIn() {
|
|
188
|
+
const cdp = await getSession();
|
|
189
|
+
let href = await cdp.currentUrl().catch(() => '');
|
|
190
|
+
if (!String(href).startsWith(WECHAT_MP_ORIGIN)) {
|
|
191
|
+
await cdp.navigate(`${WECHAT_MP_ORIGIN}/cgi-bin/home?t=home/index&lang=zh_CN`);
|
|
192
|
+
href = await cdp.currentUrl();
|
|
193
|
+
}
|
|
194
|
+
const token = new URL(String(href)).searchParams.get('token') ?? '';
|
|
195
|
+
if (!token) {
|
|
196
|
+
throw new Error('LOGIN_REQUIRED:WeChat MP backend login is required');
|
|
197
|
+
}
|
|
198
|
+
return { cdp, token };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function throttle() {
|
|
202
|
+
const elapsed = Date.now() - lastRequestAt;
|
|
203
|
+
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
|
204
|
+
await sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
|
|
205
|
+
}
|
|
206
|
+
lastRequestAt = Date.now();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function cacheGet(key) {
|
|
210
|
+
const hit = cache.get(key);
|
|
211
|
+
if (!hit || hit.expiresAt < Date.now()) {
|
|
212
|
+
cache.delete(key);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return hit.value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cacheSet(key, value) {
|
|
219
|
+
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function browserFetchJson(cdp, url) {
|
|
223
|
+
await throttle();
|
|
224
|
+
const payload = await cdp.evalValue(`(async () => {
|
|
225
|
+
const response = await fetch(${JSON.stringify(url)}, {
|
|
226
|
+
method: 'GET',
|
|
227
|
+
credentials: 'include',
|
|
228
|
+
headers: {
|
|
229
|
+
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
|
230
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
const text = await response.text();
|
|
234
|
+
return JSON.stringify({ ok: response.ok, status: response.status, text });
|
|
235
|
+
})()`);
|
|
236
|
+
const envelope = parseJsonMaybe(payload);
|
|
237
|
+
if (!envelope) throw new Error('PARSE_FAILED:invalid browser fetch envelope');
|
|
238
|
+
const parsed = parseJsonMaybe(envelope.text);
|
|
239
|
+
if (!envelope.ok) throw new Error(`WECHAT_HTTP_${envelope.status}:${String(envelope.text).slice(0, 160)}`);
|
|
240
|
+
if (!parsed) throw new Error(`PARSE_FAILED:invalid JSON response:${String(envelope.text).slice(0, 160)}`);
|
|
241
|
+
const ret = parsed?.base_resp?.ret;
|
|
242
|
+
if (ret != null && Number(ret) !== 0) {
|
|
243
|
+
const errMsg = parsed?.base_resp?.err_msg ?? parsed?.base_resp?.errmsg ?? 'wechat_base_resp_error';
|
|
244
|
+
if (String(errMsg).toLowerCase().includes('freq')) throw new Error(`RATE_LIMITED:${errMsg}`);
|
|
245
|
+
throw new Error(`WECHAT_RISK_CONTROL:${ret}:${errMsg}`);
|
|
246
|
+
}
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function fetchText(url) {
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(url, {
|
|
255
|
+
headers: {
|
|
256
|
+
'User-Agent': 'Mozilla/5.0 (wechat-mp-fetch-mcp)',
|
|
257
|
+
'Accept': 'text/html,*/*',
|
|
258
|
+
},
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
});
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
if (!res.ok) throw new Error(`WECHAT_HTTP_${res.status}:${text.slice(0, 160)}`);
|
|
263
|
+
return text;
|
|
264
|
+
} finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function checkGovernance(toolName, toolInput) {
|
|
270
|
+
if (!GOVERNANCE_SPAWN_BUNDLE_ID || !GOVERNANCE_POLICY_VERSION) return toolInput;
|
|
271
|
+
if (!SERVER_URL || !MACHINE_API_KEY || !AGENT_ID) throw new Error('governance_env_incomplete');
|
|
272
|
+
const response = await fetch(`${SERVER_URL}/governance/mcp-call`, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
'Content-Type': 'application/json',
|
|
276
|
+
'Authorization': `Bearer ${MACHINE_API_KEY}`,
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify({
|
|
279
|
+
spawn_bundle_id: GOVERNANCE_SPAWN_BUNDLE_ID,
|
|
280
|
+
policy_version: GOVERNANCE_POLICY_VERSION,
|
|
281
|
+
tool_name: toolName,
|
|
282
|
+
tool_input: toolInput,
|
|
283
|
+
tool_classification: 'mandatory',
|
|
284
|
+
agent_id: AGENT_ID,
|
|
285
|
+
idempotency_key: randomUUID(),
|
|
286
|
+
lease_id: GOVERNANCE_POLICY_LEASE?.lease_id ?? null,
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
const text = await response.text();
|
|
290
|
+
const body = parseJsonMaybe(text) ?? {};
|
|
291
|
+
if (!response.ok) throw new Error(`governance_http_${response.status}:${text.slice(0, 120)}`);
|
|
292
|
+
if (body.verdict === 'reject' || body.verdict === 'defer_human') {
|
|
293
|
+
throw new Error(`${body?.reason?.code ?? 'governance_blocked'}:${body?.reason?.message ?? 'governance rejected request'}`);
|
|
294
|
+
}
|
|
295
|
+
if (body.verdict === 'modify' && body.modified_input && typeof body.modified_input === 'object') {
|
|
296
|
+
return { ...toolInput, ...body.modified_input };
|
|
297
|
+
}
|
|
298
|
+
return toolInput;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function searchAccounts(input) {
|
|
302
|
+
const query = String(input.query ?? '').trim();
|
|
303
|
+
if (!query) throw new Error('INVALID_INPUT:query is required');
|
|
304
|
+
const count = clampInt(input.count, 1, 10, 5);
|
|
305
|
+
const cacheKey = `search:${query}:${count}`;
|
|
306
|
+
const cached = cacheGet(cacheKey);
|
|
307
|
+
if (cached) return { ...cached, cache_hit: true };
|
|
308
|
+
|
|
309
|
+
const { cdp, token } = await ensureLoggedIn();
|
|
310
|
+
const url = new URL(`${WECHAT_MP_ORIGIN}/cgi-bin/searchbiz`);
|
|
311
|
+
url.searchParams.set('action', 'search_biz');
|
|
312
|
+
url.searchParams.set('begin', '0');
|
|
313
|
+
url.searchParams.set('count', String(count));
|
|
314
|
+
url.searchParams.set('query', query);
|
|
315
|
+
if (FINGERPRINT) url.searchParams.set('fingerprint', FINGERPRINT);
|
|
316
|
+
url.searchParams.set('token', token);
|
|
317
|
+
url.searchParams.set('lang', 'zh_CN');
|
|
318
|
+
url.searchParams.set('f', 'json');
|
|
319
|
+
url.searchParams.set('ajax', '1');
|
|
320
|
+
const payload = await browserFetchJson(cdp, url.toString());
|
|
321
|
+
const result = {
|
|
322
|
+
query,
|
|
323
|
+
accounts: normalizeSearchbizResponse(payload),
|
|
324
|
+
fetched_at: new Date().toISOString(),
|
|
325
|
+
source_type: 'wechat_mp',
|
|
326
|
+
};
|
|
327
|
+
cacheSet(cacheKey, result);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function listRecentArticles(input) {
|
|
332
|
+
const limit = clampInt(input.limit, 1, 10, 5);
|
|
333
|
+
let fakeid = String(input.fakeid ?? '').trim();
|
|
334
|
+
let account = null;
|
|
335
|
+
if (!fakeid) {
|
|
336
|
+
const accountQuery = String(input.account_query ?? '').trim();
|
|
337
|
+
if (!accountQuery) throw new Error('INVALID_INPUT:fakeid or account_query is required');
|
|
338
|
+
const search = await searchAccounts({ query: accountQuery, count: 1 });
|
|
339
|
+
account = search.accounts[0] ?? null;
|
|
340
|
+
fakeid = account?.fakeid ?? '';
|
|
341
|
+
}
|
|
342
|
+
if (!fakeid) throw new Error('NOT_FOUND:no matching WeChat MP account');
|
|
343
|
+
const cacheKey = `articles:${fakeid}:${limit}`;
|
|
344
|
+
const cached = cacheGet(cacheKey);
|
|
345
|
+
if (cached) return { ...cached, cache_hit: true };
|
|
346
|
+
|
|
347
|
+
const { cdp, token } = await ensureLoggedIn();
|
|
348
|
+
const url = new URL(`${WECHAT_MP_ORIGIN}/cgi-bin/appmsgpublish`);
|
|
349
|
+
url.searchParams.set('sub', 'list');
|
|
350
|
+
url.searchParams.set('search_field', 'null');
|
|
351
|
+
url.searchParams.set('begin', '0');
|
|
352
|
+
url.searchParams.set('count', String(limit));
|
|
353
|
+
url.searchParams.set('query', '');
|
|
354
|
+
url.searchParams.set('fakeid', fakeid);
|
|
355
|
+
url.searchParams.set('type', '101_1');
|
|
356
|
+
url.searchParams.set('free_publish_type', '1');
|
|
357
|
+
url.searchParams.set('sub_action', 'list_ex');
|
|
358
|
+
if (FINGERPRINT) url.searchParams.set('fingerprint', FINGERPRINT);
|
|
359
|
+
url.searchParams.set('token', token);
|
|
360
|
+
url.searchParams.set('lang', 'zh_CN');
|
|
361
|
+
url.searchParams.set('f', 'json');
|
|
362
|
+
url.searchParams.set('ajax', '1');
|
|
363
|
+
const payload = await browserFetchJson(cdp, url.toString());
|
|
364
|
+
const normalized = normalizeAppmsgPublishResponse(payload);
|
|
365
|
+
const result = {
|
|
366
|
+
fakeid,
|
|
367
|
+
account,
|
|
368
|
+
articles: normalized.articles.slice(0, limit),
|
|
369
|
+
total_count: normalized.total_count,
|
|
370
|
+
fetched_at: new Date().toISOString(),
|
|
371
|
+
source_type: 'wechat_mp',
|
|
372
|
+
};
|
|
373
|
+
cacheSet(cacheKey, result);
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function fetchArticle(input) {
|
|
378
|
+
const url = assertWechatArticleUrl(input.url);
|
|
379
|
+
const cacheKey = `article:${url}`;
|
|
380
|
+
const cached = cacheGet(cacheKey);
|
|
381
|
+
if (cached) return { ...cached, cache_hit: true };
|
|
382
|
+
const html = await fetchText(url);
|
|
383
|
+
const result = {
|
|
384
|
+
...parseArticleHtml(html, url),
|
|
385
|
+
fetched_at: new Date().toISOString(),
|
|
386
|
+
};
|
|
387
|
+
if (!result.content_text) throw new Error('PARSE_FAILED:article body #js_content not found');
|
|
388
|
+
cacheSet(cacheKey, result);
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function toolResult(value) {
|
|
393
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function toolError(error) {
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error ?? 'unknown_error');
|
|
398
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const server = new McpServer({ name: 'wechat-mp-fetch', version: '0.1.0' });
|
|
402
|
+
|
|
403
|
+
server.tool(
|
|
404
|
+
'wechat_mp_check_login',
|
|
405
|
+
'Check whether the injected WeChat MP browser profile is logged in.',
|
|
406
|
+
{},
|
|
407
|
+
async () => {
|
|
408
|
+
try {
|
|
409
|
+
const { token } = await ensureLoggedIn();
|
|
410
|
+
return toolResult({ ok: true, logged_in: true, token_present: Boolean(token), source_type: 'wechat_mp' });
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return toolError(error);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
server.tool(
|
|
418
|
+
'wechat_mp_search_accounts',
|
|
419
|
+
'Search WeChat Official Accounts by name or alias using the logged-in MP backend session.',
|
|
420
|
+
{
|
|
421
|
+
query: z.string().describe('Official account name or WeChat alias'),
|
|
422
|
+
count: z.number().optional().describe('Max candidates, default 5, range 1-10'),
|
|
423
|
+
},
|
|
424
|
+
async (input) => {
|
|
425
|
+
try {
|
|
426
|
+
const checked = await checkGovernance('wechat_mp_search_accounts', { ...input, source_risk: 'private_backend_session' });
|
|
427
|
+
return toolResult(await searchAccounts(checked));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return toolError(error);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
server.tool(
|
|
435
|
+
'wechat_mp_list_recent_articles',
|
|
436
|
+
'Fetch latest public articles for a WeChat Official Account fakeid or query.',
|
|
437
|
+
{
|
|
438
|
+
fakeid: z.string().optional().describe('WeChat MP fakeid from search results'),
|
|
439
|
+
account_query: z.string().optional().describe('Account name or alias; used only when fakeid is absent'),
|
|
440
|
+
limit: z.number().optional().describe('Max recent articles, default 5, range 1-10'),
|
|
441
|
+
},
|
|
442
|
+
async (input) => {
|
|
443
|
+
try {
|
|
444
|
+
const checked = await checkGovernance('wechat_mp_list_recent_articles', { ...input, source_risk: 'private_backend_session' });
|
|
445
|
+
return toolResult(await listRecentArticles(checked));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return toolError(error);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
server.tool(
|
|
453
|
+
'wechat_mp_fetch_article',
|
|
454
|
+
'Fetch and parse one WeChat MP article body from a public mp.weixin.qq.com URL.',
|
|
455
|
+
{
|
|
456
|
+
url: z.string().describe('https://mp.weixin.qq.com article URL'),
|
|
457
|
+
},
|
|
458
|
+
async (input) => {
|
|
459
|
+
try {
|
|
460
|
+
const checked = await checkGovernance('wechat_mp_fetch_article', { ...input, source_risk: 'public_web_article' });
|
|
461
|
+
return toolResult(await fetchArticle(checked));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return toolError(error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const transport = new StdioServerTransport();
|
|
469
|
+
await server.connect(transport);
|
|
470
|
+
console.error('[wechat-mp-fetch] MCP Server started');
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export const WECHAT_MP_ORIGIN = 'https://mp.weixin.qq.com';
|
|
2
|
+
|
|
3
|
+
export function clampInt(value, min, max, fallback) {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
if (!Number.isFinite(n)) return fallback;
|
|
6
|
+
return Math.max(min, Math.min(max, Math.trunc(n)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseJsonMaybe(value) {
|
|
10
|
+
if (!value || typeof value !== 'string') return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(value);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeAccount(raw = {}) {
|
|
19
|
+
return {
|
|
20
|
+
fakeid: String(raw.fakeid ?? '').trim(),
|
|
21
|
+
nickname: String(raw.nickname ?? '').trim(),
|
|
22
|
+
alias: String(raw.alias ?? raw.username ?? '').trim(),
|
|
23
|
+
avatar_url: String(raw.round_head_img ?? raw.head_img ?? raw.avatar_url ?? '').trim(),
|
|
24
|
+
signature: String(raw.signature ?? '').trim(),
|
|
25
|
+
service_type: Number.isFinite(Number(raw.service_type)) ? Number(raw.service_type) : null,
|
|
26
|
+
verify_status: Number.isFinite(Number(raw.verify_status)) ? Number(raw.verify_status) : null,
|
|
27
|
+
source_type: 'wechat_mp',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeSearchbizResponse(payload = {}) {
|
|
32
|
+
const list = Array.isArray(payload.list)
|
|
33
|
+
? payload.list
|
|
34
|
+
: Array.isArray(payload.data?.list)
|
|
35
|
+
? payload.data.list
|
|
36
|
+
: [];
|
|
37
|
+
return list
|
|
38
|
+
.map(normalizeAccount)
|
|
39
|
+
.filter((item) => item.fakeid && item.nickname);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unixToIso(value) {
|
|
43
|
+
const n = Number(value);
|
|
44
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
45
|
+
return new Date(n * 1000).toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeArticle(raw = {}, sentInfo = {}) {
|
|
49
|
+
const appmsgid = raw.appmsgid ?? raw.appmsg_id ?? raw.mid ?? null;
|
|
50
|
+
const itemidx = raw.itemidx ?? raw.item_idx ?? raw.idx ?? null;
|
|
51
|
+
const publishedAt = unixToIso(sentInfo.time)
|
|
52
|
+
|| unixToIso(raw.update_time)
|
|
53
|
+
|| unixToIso(raw.create_time);
|
|
54
|
+
return {
|
|
55
|
+
title: String(raw.title ?? '').trim(),
|
|
56
|
+
digest: String(raw.digest ?? '').trim(),
|
|
57
|
+
url: String(raw.link ?? raw.url ?? '').trim(),
|
|
58
|
+
cover_url: String(raw.cover ?? raw.cover_url ?? '').trim(),
|
|
59
|
+
published_at: publishedAt,
|
|
60
|
+
appmsgid: appmsgid == null ? null : Number(appmsgid),
|
|
61
|
+
itemidx: itemidx == null ? null : Number(itemidx),
|
|
62
|
+
source_type: 'wechat_mp',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeAppmsgPublishResponse(payload = {}) {
|
|
67
|
+
const publishPage = typeof payload.publish_page === 'string'
|
|
68
|
+
? parseJsonMaybe(payload.publish_page)
|
|
69
|
+
: payload.publish_page;
|
|
70
|
+
const publishList = Array.isArray(publishPage?.publish_list) ? publishPage.publish_list : [];
|
|
71
|
+
const articles = [];
|
|
72
|
+
|
|
73
|
+
for (const item of publishList) {
|
|
74
|
+
const publishInfo = typeof item?.publish_info === 'string'
|
|
75
|
+
? parseJsonMaybe(item.publish_info)
|
|
76
|
+
: item?.publish_info;
|
|
77
|
+
if (!publishInfo) continue;
|
|
78
|
+
const sentInfo = publishInfo.sent_info && typeof publishInfo.sent_info === 'object'
|
|
79
|
+
? publishInfo.sent_info
|
|
80
|
+
: {};
|
|
81
|
+
const primary = publishInfo.appmsg_info && typeof publishInfo.appmsg_info === 'object'
|
|
82
|
+
? publishInfo.appmsg_info
|
|
83
|
+
: null;
|
|
84
|
+
const extras = Array.isArray(publishInfo.appmsgex) ? publishInfo.appmsgex : [];
|
|
85
|
+
const rows = primary ? [primary, ...extras] : extras;
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const normalized = normalizeArticle(row, sentInfo);
|
|
88
|
+
if (normalized.title && normalized.url) articles.push(normalized);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
total_count: Number(publishPage?.total_count ?? articles.length),
|
|
94
|
+
articles,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function assertWechatArticleUrl(rawUrl) {
|
|
99
|
+
let url;
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(String(rawUrl ?? ''));
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error('INVALID_URL:article url is invalid');
|
|
104
|
+
}
|
|
105
|
+
if (url.protocol !== 'https:' || url.hostname !== 'mp.weixin.qq.com') {
|
|
106
|
+
throw new Error('INVALID_URL:only https://mp.weixin.qq.com article urls are allowed');
|
|
107
|
+
}
|
|
108
|
+
return url.toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function decodeHtmlEntities(value) {
|
|
112
|
+
return String(value ?? '')
|
|
113
|
+
.replace(/ /g, ' ')
|
|
114
|
+
.replace(/&/g, '&')
|
|
115
|
+
.replace(/</g, '<')
|
|
116
|
+
.replace(/>/g, '>')
|
|
117
|
+
.replace(/"/g, '"')
|
|
118
|
+
.replace(/'/g, "'")
|
|
119
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
120
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function stripHtml(html) {
|
|
124
|
+
return decodeHtmlEntities(String(html ?? '')
|
|
125
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
126
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
127
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
128
|
+
.replace(/<\/p>/gi, '\n')
|
|
129
|
+
.replace(/<[^>]+>/g, ' ')
|
|
130
|
+
.replace(/[ \t\r\f\v]+/g, ' ')
|
|
131
|
+
.replace(/\n\s+/g, '\n')
|
|
132
|
+
.replace(/\n{3,}/g, '\n\n'))
|
|
133
|
+
.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function metaContent(html, propertyOrName) {
|
|
137
|
+
const escaped = propertyOrName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
|
+
const re = new RegExp(`<meta[^>]+(?:property|name)=["']${escaped}["'][^>]+content=["']([^"']*)["'][^>]*>`, 'i');
|
|
139
|
+
const reverse = new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${escaped}["'][^>]*>`, 'i');
|
|
140
|
+
return decodeHtmlEntities(re.exec(html)?.[1] ?? reverse.exec(html)?.[1] ?? '').trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseArticleHtml(html, url = '') {
|
|
144
|
+
const raw = String(html ?? '');
|
|
145
|
+
const contentMatch = raw.match(/<div[^>]+id=["']js_content["'][^>]*>([\s\S]*?)<\/div>\s*(?:<script|<style|<div|<\/body>|$)/i);
|
|
146
|
+
const contentHtml = (contentMatch?.[1] ?? '').trim();
|
|
147
|
+
const title = metaContent(raw, 'og:title')
|
|
148
|
+
|| decodeHtmlEntities(raw.match(/var\s+msg_title\s*=\s*["']([^"']*)["']/)?.[1] ?? '')
|
|
149
|
+
|| decodeHtmlEntities(raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '').trim();
|
|
150
|
+
const author = decodeHtmlEntities(raw.match(/var\s+nickname\s*=\s*["']([^"']*)["']/)?.[1] ?? '')
|
|
151
|
+
|| metaContent(raw, 'og:article:author')
|
|
152
|
+
|| '';
|
|
153
|
+
const images = [...contentHtml.matchAll(/<img[^>]+(?:data-src|src)=["']([^"']+)["'][^>]*>/gi)]
|
|
154
|
+
.map((match) => decodeHtmlEntities(match[1]).trim())
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
title,
|
|
159
|
+
author,
|
|
160
|
+
url,
|
|
161
|
+
content_text: stripHtml(contentHtml),
|
|
162
|
+
content_html: contentHtml,
|
|
163
|
+
images,
|
|
164
|
+
source_type: 'wechat_mp',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "wechat-mp-fetch",
|
|
3
|
+
"name": "WeChat MP Fetch MCP Server",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "wechat_mp_check_login", "classification": "mandatory" },
|
|
9
|
+
{ "name": "wechat_mp_search_accounts", "classification": "mandatory" },
|
|
10
|
+
{ "name": "wechat_mp_list_recent_articles", "classification": "mandatory" },
|
|
11
|
+
{ "name": "wechat_mp_fetch_article", "classification": "mandatory" }
|
|
12
|
+
],
|
|
13
|
+
"smoke_test": {
|
|
14
|
+
"tool": "wechat_mp_check_login",
|
|
15
|
+
"arguments": {}
|
|
16
|
+
}
|
|
17
|
+
}
|
package/package.json
CHANGED
package/src/agent-manager.js
CHANGED
|
@@ -689,6 +689,7 @@ export class AgentManager {
|
|
|
689
689
|
'${DOUYIN_PROFILE_DIR}': path.join(profileRoot, `douyin-${userId}`),
|
|
690
690
|
'${KUAISHOU_PROFILE_DIR}': path.join(profileRoot, `kuaishou-${userId}`),
|
|
691
691
|
'${BILIBILI_PROFILE_DIR}': path.join(profileRoot, `bilibili-${userId}`),
|
|
692
|
+
'${WECHAT_MP_PROFILE_DIR}': path.join(profileRoot, `wechat_mp-${userId}`),
|
|
692
693
|
};
|
|
693
694
|
const mcpServers = this._resolveDirectiveMcpServers(directive, baseReplacements);
|
|
694
695
|
|
package/src/mcp-config.js
CHANGED
|
@@ -7,6 +7,7 @@ const LEGACY_MCP_PATH_TOKENS = Object.freeze({
|
|
|
7
7
|
'{platform_mcp_path}': 'platform',
|
|
8
8
|
'{publisher_mcp_path}': 'publisher',
|
|
9
9
|
'{research_fetch_mcp_path}': 'research-fetch',
|
|
10
|
+
'{wechat_mp_fetch_mcp_path}': 'wechat-mp-fetch',
|
|
10
11
|
'{market_data_query_mcp_path}': 'market-data-query',
|
|
11
12
|
'{company_fundamentals_mcp_path}': 'company-fundamentals',
|
|
12
13
|
'{industry_report_mcp_path}': 'industry-report',
|
|
@@ -70,6 +71,7 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
|
|
|
70
71
|
|| serverKey === 'publisher'
|
|
71
72
|
|| serverKey === 'platform'
|
|
72
73
|
|| serverKey === 'research-fetch'
|
|
74
|
+
|| serverKey === 'wechat-mp-fetch'
|
|
73
75
|
|| serverKey === 'market-data-query'
|
|
74
76
|
|| serverKey === 'company-fundamentals'
|
|
75
77
|
|| serverKey === 'industry-report'
|