devin-search-mcp 1.0.4 → 1.0.5

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/fetch.mjs +108 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devin-search-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Devin AI 驱动的联网搜索与网页抓取 MCP 服务 (基于 Devin Desktop / CLI)",
5
5
  "main": "src/index.mjs",
6
6
  "type": "module",
package/src/fetch.mjs CHANGED
@@ -1,23 +1,78 @@
1
1
  /**
2
2
  * Devin 网页抓取与阅读模块
3
3
  * 调用 Devin CLI 的 webfetch 工具提取指定网页的清洁正文
4
+ * 支持 retryable 错误自动重试与原生网络抓取兜底
4
5
  */
5
6
 
6
7
  import { execFile } from "node:child_process";
7
8
  import { findDevinExecutable } from "./detector.mjs";
8
9
  import { searchCache } from "./cache.mjs";
9
10
 
11
+ /**
12
+ * 极简 HTML 清洗工具 (原生兜底用)
13
+ */
14
+ function cleanHtmlToMarkdown(html) {
15
+ let text = html
16
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
17
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
18
+ .replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, "")
19
+ .replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, "");
20
+
21
+ // 简易转换为 Markdown
22
+ text = text
23
+ .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n")
24
+ .replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n")
25
+ .replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n")
26
+ .replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "\n$1\n")
27
+ .replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "\n* $1")
28
+ .replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
29
+ .replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, "\n```\n$1\n```\n")
30
+ .replace(/<[^>]+>/g, " ")
31
+ .replace(/&nbsp;/g, " ")
32
+ .replace(/&amp;/g, "&")
33
+ .replace(/&lt;/g, "<")
34
+ .replace(/&gt;/g, ">")
35
+ .replace(/&quot;/g, '"')
36
+ .replace(/\n\s*\n\s*\n/g, "\n\n")
37
+ .trim();
38
+
39
+ return text;
40
+ }
41
+
42
+ /**
43
+ * 原生 Fetch 极速抓取兜底
44
+ */
45
+ async function nativeFetchFallback(url) {
46
+ const resp = await fetch(url, {
47
+ headers: {
48
+ "User-Agent":
49
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
50
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
51
+ },
52
+ signal: AbortSignal.timeout(20000),
53
+ });
54
+
55
+ if (!resp.ok) {
56
+ throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
57
+ }
58
+
59
+ const html = await resp.text();
60
+ return cleanHtmlToMarkdown(html);
61
+ }
62
+
10
63
  /**
11
64
  * 抓取指定 URL 的网页内容
12
65
  * @param {Object} options
13
66
  * @param {string} options.url - 目标网页 URL
14
67
  * @param {"markdown"|"text"|"summary"} [options.extractMode="markdown"] - 提取格式
15
- * @param {number} [options.timeoutMs=35000] - 超时毫秒数
68
+ * @param {number} [options.timeoutMs=60000] - 超时毫秒数
69
+ * @param {number} [options.maxRetries=2] - 重试次数
16
70
  */
17
71
  export async function executeWebFetch({
18
72
  url,
19
73
  extractMode = "markdown",
20
74
  timeoutMs = parseInt(process.env.DEVIN_TIMEOUT_MS || "60000", 10),
75
+ maxRetries = 2,
21
76
  }) {
22
77
  if (!url || typeof url !== "string" || !/^https?:\/\//i.test(url)) {
23
78
  throw new Error("请提供有效的 HTTP/HTTPS 网页 URL");
@@ -30,9 +85,6 @@ export async function executeWebFetch({
30
85
  }
31
86
 
32
87
  const devinExe = findDevinExecutable();
33
- if (!devinExe) {
34
- throw new Error("未找到 Devin 可执行文件,请确认已安装 Devin 或设置 DEVIN_PATH 环境变量。");
35
- }
36
88
 
37
89
  let prompt = "";
38
90
  if (extractMode === "summary") {
@@ -43,29 +95,61 @@ export async function executeWebFetch({
43
95
  prompt = `请使用 webfetch 工具读取 ${url} 的内容,并将正文内容转换为整洁、规范的 Markdown 格式输出。不要添加任何额外多余的寒暄。`;
44
96
  }
45
97
 
46
- const output = await new Promise((resolve, reject) => {
47
- execFile(
48
- devinExe,
49
- ["--permission-mode", "dangerous", "--respect-workspace-trust", "false", "-p", prompt],
50
- {
51
- timeout: timeoutMs,
52
- maxBuffer: 10 * 1024 * 1024,
53
- encoding: "utf-8",
54
- env: { ...process.env, DEVIN_PERMISSION_MODE: "dangerous" },
55
- },
56
- (error, stdout, stderr) => {
57
- if (error) {
58
- if (error.killed) {
59
- reject(new Error(`网页读取超时(超过 ${timeoutMs / 1000} 秒): ${url}`));
60
- } else {
61
- reject(new Error(`网页读取失败: ${error.message}\n${stderr || ""}`));
98
+ const runDevinAttempt = () =>
99
+ new Promise((resolve, reject) => {
100
+ execFile(
101
+ devinExe,
102
+ ["--permission-mode", "dangerous", "--respect-workspace-trust", "false", "-p", prompt],
103
+ {
104
+ timeout: timeoutMs,
105
+ maxBuffer: 10 * 1024 * 1024,
106
+ encoding: "utf-8",
107
+ env: { ...process.env, DEVIN_PERMISSION_MODE: "dangerous" },
108
+ },
109
+ (error, stdout, stderr) => {
110
+ if (error) {
111
+ if (error.killed) {
112
+ reject(new Error(`网页读取超时(超过 ${timeoutMs / 1000} 秒): ${url}`));
113
+ } else {
114
+ reject(new Error(`${error.message}\n${stderr || ""}`));
115
+ }
116
+ return;
62
117
  }
63
- return;
118
+ resolve(stdout.trim());
64
119
  }
65
- resolve(stdout.trim());
120
+ );
121
+ });
122
+
123
+ let output = "";
124
+ let lastErr = null;
125
+
126
+ if (devinExe) {
127
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
128
+ try {
129
+ output = await runDevinAttempt();
130
+ if (output) break;
131
+ } catch (err) {
132
+ lastErr = err;
133
+ // 如果是可重试的频控错误,进行指数退避
134
+ if (/resource_exhausted|retryable|ETIMEDOUT/i.test(err.message)) {
135
+ if (attempt < maxRetries) {
136
+ await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
137
+ continue;
138
+ }
139
+ }
140
+ break;
66
141
  }
67
- );
68
- });
142
+ }
143
+ }
144
+
145
+ // 如果 Devin 暂时频控或遇到网络故障,自动无缝降级走本地原生 fetch 提取
146
+ if (!output) {
147
+ try {
148
+ output = await nativeFetchFallback(url);
149
+ } catch (fallbackErr) {
150
+ throw new Error(`网页抓取失败: ${lastErr?.message || fallbackErr.message}`);
151
+ }
152
+ }
69
153
 
70
154
  const resultPayload = {
71
155
  url,