mcp-researchpowerpack 3.9.4 → 4.3.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 (188) hide show
  1. package/README.md +116 -208
  2. package/dist/index.js +280 -337
  3. package/dist/index.js.map +7 -1
  4. package/dist/mcp-use.json +7 -0
  5. package/dist/src/clients/reddit.js +278 -0
  6. package/dist/src/clients/reddit.js.map +7 -0
  7. package/dist/src/clients/scraper.js +326 -0
  8. package/dist/src/clients/scraper.js.map +7 -0
  9. package/dist/src/clients/search.js +217 -0
  10. package/dist/src/clients/search.js.map +7 -0
  11. package/dist/src/config/index.js +138 -0
  12. package/dist/src/config/index.js.map +7 -0
  13. package/dist/src/prompts/deep-research.js +41 -0
  14. package/dist/src/prompts/deep-research.js.map +7 -0
  15. package/dist/src/prompts/reddit-sentiment.js +47 -0
  16. package/dist/src/prompts/reddit-sentiment.js.map +7 -0
  17. package/dist/src/schemas/reddit.js +21 -0
  18. package/dist/src/schemas/reddit.js.map +7 -0
  19. package/dist/src/schemas/scrape-links.js +26 -0
  20. package/dist/src/schemas/scrape-links.js.map +7 -0
  21. package/dist/src/schemas/start-research.js +17 -0
  22. package/dist/src/schemas/start-research.js.map +7 -0
  23. package/dist/src/schemas/web-search.js +53 -0
  24. package/dist/src/schemas/web-search.js.map +7 -0
  25. package/dist/src/services/llm-processor.js +675 -0
  26. package/dist/src/services/llm-processor.js.map +7 -0
  27. package/dist/src/services/markdown-cleaner.js +62 -0
  28. package/dist/src/services/markdown-cleaner.js.map +7 -0
  29. package/dist/src/services/workflow-state.js +116 -0
  30. package/dist/src/services/workflow-state.js.map +7 -0
  31. package/dist/src/tools/mcp-helpers.js +50 -0
  32. package/dist/src/tools/mcp-helpers.js.map +7 -0
  33. package/dist/src/tools/reddit.js +277 -0
  34. package/dist/src/tools/reddit.js.map +7 -0
  35. package/dist/src/tools/registry.js +18 -0
  36. package/dist/src/tools/registry.js.map +7 -0
  37. package/dist/src/tools/scrape.js +334 -0
  38. package/dist/src/tools/scrape.js.map +7 -0
  39. package/dist/src/tools/search.js +423 -0
  40. package/dist/src/tools/search.js.map +7 -0
  41. package/dist/src/tools/start-research.js +199 -0
  42. package/dist/src/tools/start-research.js.map +7 -0
  43. package/dist/src/tools/utils.js +29 -0
  44. package/dist/src/tools/utils.js.map +7 -0
  45. package/dist/src/utils/bootstrap-guard.js +27 -0
  46. package/dist/src/utils/bootstrap-guard.js.map +7 -0
  47. package/dist/src/utils/concurrency.js +62 -0
  48. package/dist/src/utils/concurrency.js.map +7 -0
  49. package/dist/src/utils/content-extractor.js +61 -0
  50. package/dist/src/utils/content-extractor.js.map +7 -0
  51. package/dist/src/utils/errors.js +211 -0
  52. package/dist/src/utils/errors.js.map +7 -0
  53. package/dist/src/utils/logger.js +25 -0
  54. package/dist/src/utils/logger.js.map +7 -0
  55. package/dist/src/utils/markdown-formatter.js +15 -0
  56. package/dist/src/utils/markdown-formatter.js.map +7 -0
  57. package/dist/src/utils/reddit-keyword-guard.js +29 -0
  58. package/dist/src/utils/reddit-keyword-guard.js.map +7 -0
  59. package/dist/src/utils/response.js +81 -0
  60. package/dist/src/utils/response.js.map +7 -0
  61. package/dist/src/utils/retry.js +13 -0
  62. package/dist/src/utils/retry.js.map +7 -0
  63. package/dist/src/utils/sanitize.js +10 -0
  64. package/dist/src/utils/sanitize.js.map +7 -0
  65. package/dist/src/utils/source-type.js +41 -0
  66. package/dist/src/utils/source-type.js.map +7 -0
  67. package/dist/src/utils/url-aggregator.js +227 -0
  68. package/dist/src/utils/url-aggregator.js.map +7 -0
  69. package/dist/src/utils/workflow-key.js +14 -0
  70. package/dist/src/utils/workflow-key.js.map +7 -0
  71. package/dist/src/version.js +32 -0
  72. package/dist/src/version.js.map +7 -0
  73. package/package.json +33 -28
  74. package/dist/clients/reddit.d.ts +0 -69
  75. package/dist/clients/reddit.d.ts.map +0 -1
  76. package/dist/clients/reddit.js +0 -369
  77. package/dist/clients/reddit.js.map +0 -1
  78. package/dist/clients/research.d.ts +0 -67
  79. package/dist/clients/research.d.ts.map +0 -1
  80. package/dist/clients/research.js +0 -290
  81. package/dist/clients/research.js.map +0 -1
  82. package/dist/clients/scraper.d.ts +0 -72
  83. package/dist/clients/scraper.d.ts.map +0 -1
  84. package/dist/clients/scraper.js +0 -338
  85. package/dist/clients/scraper.js.map +0 -1
  86. package/dist/clients/search.d.ts +0 -57
  87. package/dist/clients/search.d.ts.map +0 -1
  88. package/dist/clients/search.js +0 -223
  89. package/dist/clients/search.js.map +0 -1
  90. package/dist/config/index.d.ts +0 -78
  91. package/dist/config/index.d.ts.map +0 -1
  92. package/dist/config/index.js +0 -201
  93. package/dist/config/index.js.map +0 -1
  94. package/dist/config/loader.d.ts +0 -40
  95. package/dist/config/loader.d.ts.map +0 -1
  96. package/dist/config/loader.js +0 -322
  97. package/dist/config/loader.js.map +0 -1
  98. package/dist/config/types.d.ts +0 -81
  99. package/dist/config/types.d.ts.map +0 -1
  100. package/dist/config/types.js +0 -6
  101. package/dist/config/types.js.map +0 -1
  102. package/dist/config/yaml/tools.yaml +0 -146
  103. package/dist/index.d.ts +0 -7
  104. package/dist/index.d.ts.map +0 -1
  105. package/dist/schemas/deep-research.d.ts +0 -64
  106. package/dist/schemas/deep-research.d.ts.map +0 -1
  107. package/dist/schemas/deep-research.js +0 -224
  108. package/dist/schemas/deep-research.js.map +0 -1
  109. package/dist/schemas/scrape-links.d.ts +0 -32
  110. package/dist/schemas/scrape-links.d.ts.map +0 -1
  111. package/dist/schemas/scrape-links.js +0 -34
  112. package/dist/schemas/scrape-links.js.map +0 -1
  113. package/dist/schemas/web-search.d.ts +0 -22
  114. package/dist/schemas/web-search.d.ts.map +0 -1
  115. package/dist/schemas/web-search.js +0 -21
  116. package/dist/schemas/web-search.js.map +0 -1
  117. package/dist/services/file-attachment.d.ts +0 -30
  118. package/dist/services/file-attachment.d.ts.map +0 -1
  119. package/dist/services/file-attachment.js +0 -207
  120. package/dist/services/file-attachment.js.map +0 -1
  121. package/dist/services/llm-processor.d.ts +0 -29
  122. package/dist/services/llm-processor.d.ts.map +0 -1
  123. package/dist/services/llm-processor.js +0 -244
  124. package/dist/services/llm-processor.js.map +0 -1
  125. package/dist/services/markdown-cleaner.d.ts +0 -8
  126. package/dist/services/markdown-cleaner.d.ts.map +0 -1
  127. package/dist/services/markdown-cleaner.js +0 -74
  128. package/dist/services/markdown-cleaner.js.map +0 -1
  129. package/dist/tools/definitions.d.ts +0 -16
  130. package/dist/tools/definitions.d.ts.map +0 -1
  131. package/dist/tools/definitions.js +0 -17
  132. package/dist/tools/definitions.js.map +0 -1
  133. package/dist/tools/reddit.d.ts +0 -14
  134. package/dist/tools/reddit.d.ts.map +0 -1
  135. package/dist/tools/reddit.js +0 -265
  136. package/dist/tools/reddit.js.map +0 -1
  137. package/dist/tools/registry.d.ts +0 -71
  138. package/dist/tools/registry.d.ts.map +0 -1
  139. package/dist/tools/registry.js +0 -252
  140. package/dist/tools/registry.js.map +0 -1
  141. package/dist/tools/research.d.ts +0 -14
  142. package/dist/tools/research.d.ts.map +0 -1
  143. package/dist/tools/research.js +0 -196
  144. package/dist/tools/research.js.map +0 -1
  145. package/dist/tools/scrape.d.ts +0 -14
  146. package/dist/tools/scrape.d.ts.map +0 -1
  147. package/dist/tools/scrape.js +0 -234
  148. package/dist/tools/scrape.js.map +0 -1
  149. package/dist/tools/search.d.ts +0 -10
  150. package/dist/tools/search.d.ts.map +0 -1
  151. package/dist/tools/search.js +0 -158
  152. package/dist/tools/search.js.map +0 -1
  153. package/dist/tools/utils.d.ts +0 -105
  154. package/dist/tools/utils.d.ts.map +0 -1
  155. package/dist/tools/utils.js +0 -159
  156. package/dist/tools/utils.js.map +0 -1
  157. package/dist/utils/concurrency.d.ts +0 -28
  158. package/dist/utils/concurrency.d.ts.map +0 -1
  159. package/dist/utils/concurrency.js +0 -92
  160. package/dist/utils/concurrency.js.map +0 -1
  161. package/dist/utils/errors.d.ts +0 -95
  162. package/dist/utils/errors.d.ts.map +0 -1
  163. package/dist/utils/errors.js +0 -390
  164. package/dist/utils/errors.js.map +0 -1
  165. package/dist/utils/logger.d.ts +0 -39
  166. package/dist/utils/logger.d.ts.map +0 -1
  167. package/dist/utils/logger.js +0 -57
  168. package/dist/utils/logger.js.map +0 -1
  169. package/dist/utils/markdown-formatter.d.ts +0 -5
  170. package/dist/utils/markdown-formatter.d.ts.map +0 -1
  171. package/dist/utils/markdown-formatter.js +0 -15
  172. package/dist/utils/markdown-formatter.js.map +0 -1
  173. package/dist/utils/response.d.ts +0 -93
  174. package/dist/utils/response.d.ts.map +0 -1
  175. package/dist/utils/response.js +0 -170
  176. package/dist/utils/response.js.map +0 -1
  177. package/dist/utils/retry.d.ts +0 -43
  178. package/dist/utils/retry.d.ts.map +0 -1
  179. package/dist/utils/retry.js +0 -57
  180. package/dist/utils/retry.js.map +0 -1
  181. package/dist/utils/url-aggregator.d.ts +0 -90
  182. package/dist/utils/url-aggregator.d.ts.map +0 -1
  183. package/dist/utils/url-aggregator.js +0 -538
  184. package/dist/utils/url-aggregator.js.map +0 -1
  185. package/dist/version.d.ts +0 -29
  186. package/dist/version.d.ts.map +0 -1
  187. package/dist/version.js +0 -55
  188. package/dist/version.js.map +0 -1
@@ -0,0 +1,326 @@
1
+ import { parseEnv, CONCURRENCY } from "../config/index.js";
2
+ import {
3
+ classifyError,
4
+ fetchWithTimeout,
5
+ sleep,
6
+ ErrorCode
7
+ } from "../utils/errors.js";
8
+ import { calculateBackoff } from "../utils/retry.js";
9
+ import { pMapSettled } from "../utils/concurrency.js";
10
+ import { mcpLog } from "../utils/logger.js";
11
+ const SCRAPE_MODES = ["basic", "javascript", "javascript_geo"];
12
+ const CREDIT_COSTS = { basic: 1, javascript: 5, javascript_geo: 5 };
13
+ const SCRAPE_BATCH_SIZE = 30;
14
+ const MAX_RETRIES = 1;
15
+ const FALLBACK_OVERALL_TIMEOUT_MS = 3e4;
16
+ const RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 502, 503, 504, 510]);
17
+ const PERMANENT_FAILURE_CODES = /* @__PURE__ */ new Set([400, 401, 403]);
18
+ const MIN_USEFUL_CONTENT_LENGTH = 200;
19
+ const FALLBACK_ATTEMPTS = [
20
+ { mode: "basic", description: "basic mode" },
21
+ { mode: "javascript", description: "javascript rendering" },
22
+ { mode: "javascript", country: "us", description: "javascript + US geo-targeting" }
23
+ ];
24
+ class ScraperClient {
25
+ apiKey;
26
+ baseURL = "https://api.scrape.do";
27
+ constructor(apiKey) {
28
+ const env = parseEnv();
29
+ this.apiKey = apiKey || env.SCRAPER_API_KEY;
30
+ if (!this.apiKey) {
31
+ throw new Error("Web scraping capability is not configured. Please set up the required API credentials.");
32
+ }
33
+ }
34
+ /**
35
+ * Scrape a single URL with retry logic
36
+ * NEVER throws - always returns a ScrapeResponse (possibly with error)
37
+ */
38
+ async scrape(request, maxRetries = MAX_RETRIES) {
39
+ const { url, mode = "basic", timeout = 15, country } = request;
40
+ const credits = CREDIT_COSTS[mode] ?? 1;
41
+ try {
42
+ new URL(url);
43
+ } catch {
44
+ return {
45
+ content: `Invalid URL: ${url}`,
46
+ statusCode: 400,
47
+ credits: 0,
48
+ error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false }
49
+ };
50
+ }
51
+ const params = new URLSearchParams({
52
+ url,
53
+ token: this.apiKey,
54
+ timeout: String(timeout * 1e3)
55
+ });
56
+ if (mode === "javascript") {
57
+ params.append("render", "true");
58
+ }
59
+ if (country) {
60
+ params.append("geoCode", country.toUpperCase());
61
+ }
62
+ const apiUrl = `${this.baseURL}?${params.toString()}`;
63
+ let lastError;
64
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
65
+ try {
66
+ const timeoutMs = (timeout + 5) * 1e3;
67
+ const response = await fetchWithTimeout(apiUrl, {
68
+ method: "GET",
69
+ headers: { Accept: "text/html,application/json" },
70
+ timeoutMs
71
+ });
72
+ let content;
73
+ try {
74
+ content = await response.text();
75
+ } catch (readError) {
76
+ content = `Failed to read response: ${readError instanceof Error ? readError.message : String(readError)}`;
77
+ }
78
+ if (response.ok) {
79
+ return {
80
+ content,
81
+ statusCode: response.status,
82
+ credits,
83
+ headers: Object.fromEntries(response.headers.entries())
84
+ };
85
+ }
86
+ if (response.status === 404) {
87
+ return {
88
+ content: "404 - Page not found",
89
+ statusCode: 404,
90
+ credits
91
+ };
92
+ }
93
+ if (PERMANENT_FAILURE_CODES.has(response.status)) {
94
+ const errorMsg = response.status === 401 ? "No credits remaining or subscription suspended" : `Request failed with status ${response.status}`;
95
+ return {
96
+ content: `Error: ${errorMsg}`,
97
+ statusCode: response.status,
98
+ credits: 0,
99
+ error: {
100
+ code: response.status === 401 ? ErrorCode.AUTH_ERROR : ErrorCode.INVALID_INPUT,
101
+ message: errorMsg,
102
+ retryable: false,
103
+ statusCode: response.status
104
+ }
105
+ };
106
+ }
107
+ if (RETRYABLE_STATUS_CODES.has(response.status)) {
108
+ lastError = {
109
+ code: response.status === 429 ? ErrorCode.RATE_LIMITED : ErrorCode.SERVICE_UNAVAILABLE,
110
+ message: `Server returned ${response.status}`,
111
+ retryable: true,
112
+ statusCode: response.status
113
+ };
114
+ if (attempt < maxRetries - 1) {
115
+ const delayMs = calculateBackoff(attempt);
116
+ mcpLog("warning", `${response.status} on attempt ${attempt + 1}/${maxRetries}. Retrying in ${delayMs}ms`, "scraper");
117
+ await sleep(delayMs);
118
+ continue;
119
+ }
120
+ }
121
+ lastError = classifyError({ status: response.status, message: content });
122
+ if (attempt < maxRetries - 1 && lastError.retryable) {
123
+ const delayMs = calculateBackoff(attempt);
124
+ mcpLog("warning", `Status ${response.status}. Retrying in ${delayMs}ms`, "scraper");
125
+ await sleep(delayMs);
126
+ continue;
127
+ }
128
+ return {
129
+ content: `Error: ${lastError.message}`,
130
+ statusCode: response.status,
131
+ credits: 0,
132
+ error: lastError
133
+ };
134
+ } catch (error) {
135
+ lastError = classifyError(error);
136
+ if (!lastError.retryable) {
137
+ return {
138
+ content: `Error: ${lastError.message}`,
139
+ statusCode: lastError.statusCode || 500,
140
+ credits: 0,
141
+ error: lastError
142
+ };
143
+ }
144
+ if (attempt < maxRetries - 1) {
145
+ const delayMs = calculateBackoff(attempt);
146
+ mcpLog("warning", `${lastError.code}: ${lastError.message}. Retry ${attempt + 1}/${maxRetries} in ${delayMs}ms`, "scraper");
147
+ await sleep(delayMs);
148
+ continue;
149
+ }
150
+ }
151
+ }
152
+ return {
153
+ content: `Error: Failed after ${maxRetries} attempts. ${lastError?.message || "Unknown error"}`,
154
+ statusCode: lastError?.statusCode || 500,
155
+ credits: 0,
156
+ error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: "All retries exhausted", retryable: false }
157
+ };
158
+ }
159
+ /**
160
+ * Scrape with automatic fallback through different modes
161
+ * NEVER throws - always returns a ScrapeResponse
162
+ */
163
+ async scrapeWithFallback(url, options = {}) {
164
+ const attemptResults = [];
165
+ let lastResult = null;
166
+ const deadline = Date.now() + FALLBACK_OVERALL_TIMEOUT_MS;
167
+ for (const attempt of FALLBACK_ATTEMPTS) {
168
+ if (Date.now() >= deadline) {
169
+ mcpLog("warning", `Overall fallback timeout reached for ${url} after ${attemptResults.length} attempt(s)`, "scraper");
170
+ break;
171
+ }
172
+ const result = await this.tryFallbackAttempt(url, attempt, options);
173
+ if (result.done) {
174
+ if (attemptResults.length > 0) {
175
+ mcpLog("info", `Success with ${attempt.description} after ${attemptResults.length} fallback(s)`, "scraper");
176
+ }
177
+ return result.response;
178
+ }
179
+ lastResult = result.response;
180
+ attemptResults.push(`${attempt.description}: ${result.response.error?.message || result.response.statusCode}`);
181
+ mcpLog("warning", `Failed with ${attempt.description} (${result.response.statusCode}), trying next fallback...`, "scraper");
182
+ }
183
+ const errorMessage = `Failed after ${attemptResults.length} fallback attempt(s): ${attemptResults.join("; ")}`;
184
+ return {
185
+ content: `Error: ${errorMessage}`,
186
+ statusCode: lastResult?.statusCode || 500,
187
+ credits: 0,
188
+ error: {
189
+ code: ErrorCode.SERVICE_UNAVAILABLE,
190
+ message: errorMessage,
191
+ retryable: false
192
+ }
193
+ };
194
+ }
195
+ /**
196
+ * Execute a single fallback attempt and determine whether to continue.
197
+ * Returns { done: true } on success/terminal or { done: false } to try the next mode.
198
+ */
199
+ async tryFallbackAttempt(url, attempt, options) {
200
+ const result = await this.scrape({
201
+ url,
202
+ mode: attempt.mode,
203
+ timeout: options.timeout,
204
+ country: attempt.country
205
+ });
206
+ if (result.statusCode >= 200 && result.statusCode < 300 && !result.error) {
207
+ const strippedLength = result.content.replace(/<[^>]*>/g, "").trim().length;
208
+ if (strippedLength < MIN_USEFUL_CONTENT_LENGTH && attempt.mode === "basic") {
209
+ mcpLog("info", `Basic mode returned only ${strippedLength} chars of text for ${url} \u2014 trying JS rendering`, "scraper");
210
+ return { done: false, response: result };
211
+ }
212
+ return { done: true, response: result };
213
+ }
214
+ if (result.statusCode === 404) {
215
+ return { done: true, response: result };
216
+ }
217
+ if (result.statusCode === 502) {
218
+ mcpLog("warning", `502 Bad Gateway for ${url} \u2014 likely WAF/CDN block, skipping fallback modes`, "scraper");
219
+ return { done: true, response: {
220
+ ...result,
221
+ error: {
222
+ code: ErrorCode.SERVICE_UNAVAILABLE,
223
+ message: "Bad gateway \u2014 site is blocking automated access",
224
+ retryable: false
225
+ }
226
+ } };
227
+ }
228
+ if (result.error && !result.error.retryable) {
229
+ mcpLog("error", `Non-retryable error with ${attempt.description}: ${result.error.message}`, "scraper");
230
+ return { done: true, response: result };
231
+ }
232
+ return { done: false, response: result };
233
+ }
234
+ /**
235
+ * Scrape multiple URLs with batching
236
+ * NEVER throws - always returns results array
237
+ */
238
+ async scrapeMultiple(urls, options = {}) {
239
+ if (urls.length === 0) {
240
+ return [];
241
+ }
242
+ if (urls.length <= SCRAPE_BATCH_SIZE) {
243
+ return this.processBatch(urls, options);
244
+ }
245
+ const result = await this.batchScrape(urls, options);
246
+ return result.results;
247
+ }
248
+ /**
249
+ * Batch scrape with progress callback
250
+ * NEVER throws - uses Promise.allSettled internally
251
+ */
252
+ async batchScrape(urls, options = {}, onBatchComplete) {
253
+ const totalBatches = Math.ceil(urls.length / SCRAPE_BATCH_SIZE);
254
+ const allResults = [];
255
+ let rateLimitHits = 0;
256
+ mcpLog("info", `Starting batch processing: ${urls.length} URLs in ${totalBatches} batch(es)`, "scraper");
257
+ for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
258
+ const startIdx = batchNum * SCRAPE_BATCH_SIZE;
259
+ const endIdx = Math.min(startIdx + SCRAPE_BATCH_SIZE, urls.length);
260
+ const batchUrls = urls.slice(startIdx, endIdx);
261
+ mcpLog("info", `Processing batch ${batchNum + 1}/${totalBatches} (${batchUrls.length} URLs)`, "scraper");
262
+ const batchResults = await pMapSettled(
263
+ batchUrls,
264
+ (url) => this.scrapeWithFallback(url, options),
265
+ CONCURRENCY.SCRAPER
266
+ );
267
+ for (let i = 0; i < batchResults.length; i++) {
268
+ const result = batchResults[i];
269
+ if (!result) continue;
270
+ const url = batchUrls[i] ?? "";
271
+ if (result.status === "fulfilled") {
272
+ const scrapeResult = result.value;
273
+ allResults.push({ ...scrapeResult, url });
274
+ if (scrapeResult.error?.code === ErrorCode.RATE_LIMITED) {
275
+ rateLimitHits++;
276
+ }
277
+ } else {
278
+ const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
279
+ mcpLog("error", `Unexpected rejection for ${url}: ${errorMsg}`, "scraper");
280
+ allResults.push({
281
+ url,
282
+ content: `Error: Unexpected failure - ${errorMsg}`,
283
+ statusCode: 500,
284
+ credits: 0,
285
+ error: classifyError(result.reason)
286
+ });
287
+ }
288
+ }
289
+ try {
290
+ onBatchComplete?.(batchNum + 1, totalBatches, allResults.length);
291
+ } catch (callbackError) {
292
+ mcpLog("error", `onBatchComplete callback error: ${callbackError}`, "scraper");
293
+ }
294
+ mcpLog("info", `Completed batch ${batchNum + 1}/${totalBatches} (${allResults.length}/${urls.length} total)`, "scraper");
295
+ if (batchNum < totalBatches - 1) {
296
+ const batchDelay = rateLimitHits > 0 ? 2e3 : 500;
297
+ await sleep(batchDelay);
298
+ }
299
+ }
300
+ return { results: allResults, batchesProcessed: totalBatches, totalAttempted: urls.length, rateLimitHits };
301
+ }
302
+ /**
303
+ * Process a single batch of URLs
304
+ * NEVER throws
305
+ */
306
+ async processBatch(urls, options) {
307
+ const results = await pMapSettled(urls, (url) => this.scrapeWithFallback(url, options), CONCURRENCY.SCRAPER);
308
+ return results.map((result, index) => {
309
+ const url = urls[index] || "";
310
+ if (result.status === "fulfilled") {
311
+ return { ...result.value, url };
312
+ }
313
+ return {
314
+ url,
315
+ content: `Error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
316
+ statusCode: 500,
317
+ credits: 0,
318
+ error: classifyError(result.reason)
319
+ };
320
+ });
321
+ }
322
+ }
323
+ export {
324
+ ScraperClient
325
+ };
326
+ //# sourceMappingURL=scraper.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/clients/scraper.ts"],
4
+ "sourcesContent": ["/**\n * Web Scraper Client\n * Generic interface for URL scraping with automatic fallback modes\n * Implements robust error handling that NEVER crashes\n */\n\nimport { parseEnv, SCRAPER, CONCURRENCY } from '../config/index.js';\nimport {\n classifyError,\n fetchWithTimeout,\n sleep,\n ErrorCode,\n type StructuredError,\n} from '../utils/errors.js';\nimport { calculateBackoff } from '../utils/retry.js';\nimport { pMapSettled } from '../utils/concurrency.js';\nimport { mcpLog } from '../utils/logger.js';\n\n// \u2500\u2500 Constants \u2500\u2500\n\nconst SCRAPE_MODES = ['basic', 'javascript', 'javascript_geo'] as const;\ntype ScrapeMode = typeof SCRAPE_MODES[number];\n\nconst CREDIT_COSTS: Record<string, number> = { basic: 1, javascript: 5, javascript_geo: 5 } as const;\nconst SCRAPE_BATCH_SIZE = 30 as const;\nconst MAX_RETRIES = 1 as const;\n/** Overall timeout for all fallback attempts on a single URL */\nconst FALLBACK_OVERALL_TIMEOUT_MS = 30_000 as const;\n\n// \u2500\u2500 Interfaces \u2500\u2500\n\ninterface ScrapeRequest {\n readonly url: string;\n readonly mode?: 'basic' | 'javascript';\n readonly timeout?: number;\n readonly country?: string;\n}\n\ninterface ScrapeResponse {\n readonly content: string;\n readonly statusCode: number;\n readonly credits: number;\n readonly headers?: Record<string, string>;\n readonly error?: StructuredError;\n}\n\ninterface BatchScrapeResult {\n readonly results: ReadonlyArray<ScrapeResponse & { readonly url: string }>;\n readonly batchesProcessed: number;\n readonly totalAttempted: number;\n readonly rateLimitHits: number;\n}\n\n// Status codes that indicate we should retry (no credit consumed)\nconst RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504, 510]);\n// Status codes that are permanent failures (don't retry)\nconst PERMANENT_FAILURE_CODES = new Set([400, 401, 403]);\n\n/** Minimum stripped-text length to consider a scrape successful (filters out empty SPA shells) */\nconst MIN_USEFUL_CONTENT_LENGTH = 200 as const;\n\n/** Fallback attempt descriptor used by scrapeWithFallback */\ninterface FallbackAttempt {\n readonly mode: 'basic' | 'javascript';\n readonly country?: string;\n readonly description: string;\n}\n\nconst FALLBACK_ATTEMPTS: readonly FallbackAttempt[] = [\n { mode: 'basic', description: 'basic mode' },\n { mode: 'javascript', description: 'javascript rendering' },\n { mode: 'javascript', country: 'us', description: 'javascript + US geo-targeting' },\n] as const;\n\nexport class ScraperClient {\n private apiKey: string;\n private baseURL = 'https://api.scrape.do';\n\n constructor(apiKey?: string) {\n const env = parseEnv();\n this.apiKey = apiKey || env.SCRAPER_API_KEY;\n\n if (!this.apiKey) {\n throw new Error('Web scraping capability is not configured. Please set up the required API credentials.');\n }\n }\n\n /**\n * Scrape a single URL with retry logic\n * NEVER throws - always returns a ScrapeResponse (possibly with error)\n */\n async scrape(request: ScrapeRequest, maxRetries = MAX_RETRIES): Promise<ScrapeResponse> {\n const { url, mode = 'basic', timeout = 15, country } = request;\n const credits = CREDIT_COSTS[mode] ?? 1;\n\n // Validate URL first\n try {\n new URL(url);\n } catch {\n return {\n content: `Invalid URL: ${url}`,\n statusCode: 400,\n credits: 0,\n error: { code: ErrorCode.INVALID_INPUT, message: `Invalid URL: ${url}`, retryable: false },\n };\n }\n\n const params = new URLSearchParams({\n url: url,\n token: this.apiKey,\n timeout: String(timeout * 1000),\n });\n\n if (mode === 'javascript') {\n params.append('render', 'true');\n }\n\n if (country) {\n params.append('geoCode', country.toUpperCase());\n }\n\n const apiUrl = `${this.baseURL}?${params.toString()}`;\n let lastError: StructuredError | undefined;\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n // Use AbortController for timeout\n const timeoutMs = (timeout + 5) * 1000; // Add 5s buffer over scrape timeout\n const response = await fetchWithTimeout(apiUrl, {\n method: 'GET',\n headers: { Accept: 'text/html,application/json' },\n timeoutMs,\n });\n\n // Safely read response body\n let content: string;\n try {\n content = await response.text();\n } catch (readError) {\n content = `Failed to read response: ${readError instanceof Error ? readError.message : String(readError)}`;\n }\n\n // SUCCESS: 2xx - Successful API call\n if (response.ok) {\n return {\n content,\n statusCode: response.status,\n credits,\n headers: Object.fromEntries(response.headers.entries()),\n };\n }\n\n // 404 - Target not found (permanent, but not an error for our purposes)\n if (response.status === 404) {\n return {\n content: '404 - Page not found',\n statusCode: 404,\n credits,\n };\n }\n\n // Permanent failures - don't retry\n if (PERMANENT_FAILURE_CODES.has(response.status)) {\n const errorMsg = response.status === 401\n ? 'No credits remaining or subscription suspended'\n : `Request failed with status ${response.status}`;\n return {\n content: `Error: ${errorMsg}`,\n statusCode: response.status,\n credits: 0,\n error: {\n code: response.status === 401 ? ErrorCode.AUTH_ERROR : ErrorCode.INVALID_INPUT,\n message: errorMsg,\n retryable: false,\n statusCode: response.status,\n },\n };\n }\n\n // Retryable status codes\n if (RETRYABLE_STATUS_CODES.has(response.status)) {\n lastError = {\n code: response.status === 429 ? ErrorCode.RATE_LIMITED : ErrorCode.SERVICE_UNAVAILABLE,\n message: `Server returned ${response.status}`,\n retryable: true,\n statusCode: response.status,\n };\n\n if (attempt < maxRetries - 1) {\n const delayMs = calculateBackoff(attempt);\n mcpLog('warning', `${response.status} on attempt ${attempt + 1}/${maxRetries}. Retrying in ${delayMs}ms`, 'scraper');\n await sleep(delayMs);\n continue;\n }\n }\n\n // Other non-success status - treat as retryable\n lastError = classifyError({ status: response.status, message: content });\n if (attempt < maxRetries - 1 && lastError.retryable) {\n const delayMs = calculateBackoff(attempt);\n mcpLog('warning', `Status ${response.status}. Retrying in ${delayMs}ms`, 'scraper');\n await sleep(delayMs);\n continue;\n }\n\n // Final attempt failed\n return {\n content: `Error: ${lastError.message}`,\n statusCode: response.status,\n credits: 0,\n error: lastError,\n };\n\n } catch (error) {\n lastError = classifyError(error);\n\n // Non-retryable errors - return immediately\n if (!lastError.retryable) {\n return {\n content: `Error: ${lastError.message}`,\n statusCode: lastError.statusCode || 500,\n credits: 0,\n error: lastError,\n };\n }\n\n // Retryable error - continue if attempts remaining\n if (attempt < maxRetries - 1) {\n const delayMs = calculateBackoff(attempt);\n mcpLog('warning', `${lastError.code}: ${lastError.message}. Retry ${attempt + 1}/${maxRetries} in ${delayMs}ms`, 'scraper');\n await sleep(delayMs);\n continue;\n }\n }\n }\n\n // All retries exhausted\n return {\n content: `Error: Failed after ${maxRetries} attempts. ${lastError?.message || 'Unknown error'}`,\n statusCode: lastError?.statusCode || 500,\n credits: 0,\n error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: 'All retries exhausted', retryable: false },\n };\n }\n\n /**\n * Scrape with automatic fallback through different modes\n * NEVER throws - always returns a ScrapeResponse\n */\n async scrapeWithFallback(url: string, options: { timeout?: number } = {}): Promise<ScrapeResponse> {\n const attemptResults: string[] = [];\n let lastResult: ScrapeResponse | null = null;\n const deadline = Date.now() + FALLBACK_OVERALL_TIMEOUT_MS;\n\n for (const attempt of FALLBACK_ATTEMPTS) {\n // Check overall deadline before starting next fallback\n if (Date.now() >= deadline) {\n mcpLog('warning', `Overall fallback timeout reached for ${url} after ${attemptResults.length} attempt(s)`, 'scraper');\n break;\n }\n\n const result = await this.tryFallbackAttempt(url, attempt, options);\n\n if (result.done) {\n if (attemptResults.length > 0) {\n mcpLog('info', `Success with ${attempt.description} after ${attemptResults.length} fallback(s)`, 'scraper');\n }\n return result.response;\n }\n\n lastResult = result.response;\n attemptResults.push(`${attempt.description}: ${result.response.error?.message || result.response.statusCode}`);\n mcpLog('warning', `Failed with ${attempt.description} (${result.response.statusCode}), trying next fallback...`, 'scraper');\n }\n\n // All fallbacks exhausted or deadline reached\n const errorMessage = `Failed after ${attemptResults.length} fallback attempt(s): ${attemptResults.join('; ')}`;\n return {\n content: `Error: ${errorMessage}`,\n statusCode: lastResult?.statusCode || 500,\n credits: 0,\n error: {\n code: ErrorCode.SERVICE_UNAVAILABLE,\n message: errorMessage,\n retryable: false,\n },\n };\n }\n\n /**\n * Execute a single fallback attempt and determine whether to continue.\n * Returns { done: true } on success/terminal or { done: false } to try the next mode.\n */\n private async tryFallbackAttempt(\n url: string,\n attempt: FallbackAttempt,\n options: { timeout?: number },\n ): Promise<{ done: boolean; response: ScrapeResponse }> {\n const result = await this.scrape({\n url,\n mode: attempt.mode,\n timeout: options.timeout,\n country: attempt.country,\n });\n\n // Success \u2014 but verify content isn't an empty SPA shell\n if (result.statusCode >= 200 && result.statusCode < 300 && !result.error) {\n const strippedLength = result.content.replace(/<[^>]*>/g, '').trim().length;\n if (strippedLength < MIN_USEFUL_CONTENT_LENGTH && attempt.mode === 'basic') {\n mcpLog('info', `Basic mode returned only ${strippedLength} chars of text for ${url} \u2014 trying JS rendering`, 'scraper');\n return { done: false, response: result };\n }\n return { done: true, response: result };\n }\n\n // 404 is a valid response, not an error\n if (result.statusCode === 404) {\n return { done: true, response: result };\n }\n\n // 502 Bad Gateway \u2014 almost always a WAF/CDN block, not a transient issue.\n // Switching render mode won't bypass CDN protection, so fail fast.\n if (result.statusCode === 502) {\n mcpLog('warning', `502 Bad Gateway for ${url} \u2014 likely WAF/CDN block, skipping fallback modes`, 'scraper');\n return { done: true, response: {\n ...result,\n error: {\n code: ErrorCode.SERVICE_UNAVAILABLE,\n message: 'Bad gateway \u2014 site is blocking automated access',\n retryable: false,\n },\n }};\n }\n\n // Non-retryable errors - don't try other modes\n if (result.error && !result.error.retryable) {\n mcpLog('error', `Non-retryable error with ${attempt.description}: ${result.error.message}`, 'scraper');\n return { done: true, response: result };\n }\n\n return { done: false, response: result };\n }\n\n /**\n * Scrape multiple URLs with batching\n * NEVER throws - always returns results array\n */\n async scrapeMultiple(urls: string[], options: { timeout?: number } = {}): Promise<Array<ScrapeResponse & { url: string }>> {\n if (urls.length === 0) {\n return [];\n }\n\n if (urls.length <= SCRAPE_BATCH_SIZE) {\n return this.processBatch(urls, options);\n }\n\n const result = await this.batchScrape(urls, options);\n return result.results as Array<ScrapeResponse & { url: string }>;\n }\n\n /**\n * Batch scrape with progress callback\n * NEVER throws - uses Promise.allSettled internally\n */\n async batchScrape(\n urls: string[],\n options: { timeout?: number } = {},\n onBatchComplete?: (batchNum: number, totalBatches: number, processed: number) => void\n ): Promise<BatchScrapeResult> {\n const totalBatches = Math.ceil(urls.length / SCRAPE_BATCH_SIZE);\n const allResults: Array<ScrapeResponse & { url: string }> = [];\n let rateLimitHits = 0;\n\n mcpLog('info', `Starting batch processing: ${urls.length} URLs in ${totalBatches} batch(es)`, 'scraper');\n\n for (let batchNum = 0; batchNum < totalBatches; batchNum++) {\n const startIdx = batchNum * SCRAPE_BATCH_SIZE;\n const endIdx = Math.min(startIdx + SCRAPE_BATCH_SIZE, urls.length);\n const batchUrls = urls.slice(startIdx, endIdx);\n\n mcpLog('info', `Processing batch ${batchNum + 1}/${totalBatches} (${batchUrls.length} URLs)`, 'scraper');\n\n const batchResults = await pMapSettled(\n batchUrls,\n url => this.scrapeWithFallback(url, options),\n CONCURRENCY.SCRAPER\n );\n\n for (let i = 0; i < batchResults.length; i++) {\n const result = batchResults[i];\n if (!result) continue;\n const url = batchUrls[i] ?? '';\n\n if (result.status === 'fulfilled') {\n const scrapeResult = result.value;\n allResults.push({ ...scrapeResult, url });\n\n // Track rate limits\n if (scrapeResult.error?.code === ErrorCode.RATE_LIMITED) {\n rateLimitHits++;\n }\n } else {\n // This shouldn't happen since scrapeWithFallback never throws,\n // but handle it gracefully just in case\n const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);\n mcpLog('error', `Unexpected rejection for ${url}: ${errorMsg}`, 'scraper');\n\n allResults.push({\n url,\n content: `Error: Unexpected failure - ${errorMsg}`,\n statusCode: 500,\n credits: 0,\n error: classifyError(result.reason),\n });\n }\n }\n\n // Safe callback invocation\n try {\n onBatchComplete?.(batchNum + 1, totalBatches, allResults.length);\n } catch (callbackError) {\n mcpLog('error', `onBatchComplete callback error: ${callbackError}`, 'scraper');\n }\n\n mcpLog('info', `Completed batch ${batchNum + 1}/${totalBatches} (${allResults.length}/${urls.length} total)`, 'scraper');\n\n // Adaptive delay between batches \u2014 back off harder under rate limiting\n if (batchNum < totalBatches - 1) {\n const batchDelay = rateLimitHits > 0 ? 2000 : 500;\n await sleep(batchDelay);\n }\n }\n\n return { results: allResults, batchesProcessed: totalBatches, totalAttempted: urls.length, rateLimitHits };\n }\n\n /**\n * Process a single batch of URLs\n * NEVER throws\n */\n private async processBatch(urls: string[], options: { timeout?: number }): Promise<Array<ScrapeResponse & { url: string }>> {\n const results = await pMapSettled(urls, url => this.scrapeWithFallback(url, options), CONCURRENCY.SCRAPER);\n\n return results.map((result, index) => {\n const url = urls[index] || '';\n\n if (result.status === 'fulfilled') {\n return { ...result.value, url };\n }\n\n // Shouldn't happen, but handle gracefully\n return {\n url,\n content: `Error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,\n statusCode: 500,\n credits: 0,\n error: classifyError(result.reason),\n };\n });\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,217 @@
1
+ import { parseEnv, CONCURRENCY } from "../config/index.js";
2
+ import {
3
+ classifyError,
4
+ fetchWithTimeout,
5
+ sleep,
6
+ ErrorCode
7
+ } from "../utils/errors.js";
8
+ import { calculateBackoff } from "../utils/retry.js";
9
+ import { pMap } from "../utils/concurrency.js";
10
+ import { mcpLog } from "../utils/logger.js";
11
+ const SERPER_API_URL = "https://google.serper.dev/search";
12
+ const DEFAULT_RESULTS_PER_QUERY = 10;
13
+ const MAX_RETRIES = 3;
14
+ const SEARCH_RETRY_CONFIG = {
15
+ maxRetries: MAX_RETRIES,
16
+ baseDelayMs: 1e3,
17
+ maxDelayMs: 1e4,
18
+ timeoutMs: 3e4
19
+ };
20
+ const RETRYABLE_SEARCH_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
21
+ const REDDIT_SITE_REGEX = /site:\s*reddit\.com/i;
22
+ const REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\/\w+$/;
23
+ const REDDIT_SUFFIX_REGEX = / - Reddit$/;
24
+ function parseSearchResponses(responses, queries) {
25
+ return responses.map((resp, index) => {
26
+ try {
27
+ const organic = resp.organic || [];
28
+ const results = organic.map((item, idx) => ({
29
+ title: item.title || "No title",
30
+ link: item.link || "#",
31
+ snippet: item.snippet || "",
32
+ date: item.date,
33
+ position: item.position || idx + 1
34
+ }));
35
+ const searchInfo = resp.searchInformation;
36
+ const totalResults = searchInfo?.totalResults ? parseInt(String(searchInfo.totalResults).replace(/,/g, ""), 10) : results.length;
37
+ const relatedSearches = resp.relatedSearches || [];
38
+ const related = relatedSearches.map((r) => r.query || "");
39
+ return { query: queries[index] || "", results, totalResults, related };
40
+ } catch {
41
+ return { query: queries[index] || "", results: [], totalResults: 0, related: [] };
42
+ }
43
+ });
44
+ }
45
+ async function executeSearchWithRetry(apiKey, body, isRetryable) {
46
+ let lastError;
47
+ for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
48
+ try {
49
+ if (attempt > 0) {
50
+ mcpLog("warning", `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, "search");
51
+ }
52
+ const response = await fetchWithTimeout(SERPER_API_URL, {
53
+ method: "POST",
54
+ headers: {
55
+ "X-API-KEY": apiKey,
56
+ "Content-Type": "application/json"
57
+ },
58
+ body: JSON.stringify(body),
59
+ timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
60
+ });
61
+ if (!response.ok) {
62
+ const errorText = await response.text().catch(() => "");
63
+ lastError = classifyError({ status: response.status, message: errorText });
64
+ if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
65
+ const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
66
+ mcpLog("warning", `API returned ${response.status}, retrying in ${delayMs}ms...`, "search");
67
+ await sleep(delayMs);
68
+ continue;
69
+ }
70
+ return { data: void 0, error: lastError };
71
+ }
72
+ try {
73
+ const data = await response.json();
74
+ return { data };
75
+ } catch {
76
+ return {
77
+ data: void 0,
78
+ error: { code: ErrorCode.PARSE_ERROR, message: "Failed to parse search response", retryable: false }
79
+ };
80
+ }
81
+ } catch (error) {
82
+ lastError = classifyError(error);
83
+ if (isRetryable(void 0, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
84
+ const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
85
+ mcpLog("warning", `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, "search");
86
+ await sleep(delayMs);
87
+ continue;
88
+ }
89
+ return { data: void 0, error: lastError };
90
+ }
91
+ }
92
+ return {
93
+ data: void 0,
94
+ error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: "Search failed", retryable: false }
95
+ };
96
+ }
97
+ class SearchClient {
98
+ apiKey;
99
+ constructor(apiKey) {
100
+ const env = parseEnv();
101
+ this.apiKey = apiKey || env.SEARCH_API_KEY || "";
102
+ if (!this.apiKey) {
103
+ throw new Error("Web search capability is not configured. Please set up the required API credentials.");
104
+ }
105
+ }
106
+ /**
107
+ * Check if error is retryable
108
+ */
109
+ isRetryable(status, error) {
110
+ if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;
111
+ if (error == null) return false;
112
+ const message = typeof error === "object" && "message" in error && typeof error.message === "string" ? error.message.toLowerCase() : "";
113
+ return message.includes("timeout") || message.includes("rate limit") || message.includes("connection");
114
+ }
115
+ /**
116
+ * Search multiple queries in parallel
117
+ * NEVER throws - always returns a valid response
118
+ */
119
+ async searchMultiple(queries) {
120
+ const startTime = Date.now();
121
+ if (queries.length === 0) {
122
+ return {
123
+ searches: [],
124
+ totalQueries: 0,
125
+ executionTime: 0,
126
+ error: { code: ErrorCode.INVALID_INPUT, message: "No queries provided", retryable: false }
127
+ };
128
+ }
129
+ const searchQueries = queries.map((query) => ({ q: query }));
130
+ const { data, error } = await executeSearchWithRetry(
131
+ this.apiKey,
132
+ searchQueries,
133
+ (status, err) => this.isRetryable(status, err)
134
+ );
135
+ if (error || data === void 0) {
136
+ return {
137
+ searches: [],
138
+ totalQueries: queries.length,
139
+ executionTime: Date.now() - startTime,
140
+ error
141
+ };
142
+ }
143
+ const responses = Array.isArray(data) ? data : [data];
144
+ const searches = parseSearchResponses(responses, queries);
145
+ return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };
146
+ }
147
+ /**
148
+ * Search Reddit via Google (adds site:reddit.com automatically)
149
+ * NEVER throws - returns empty array on failure
150
+ */
151
+ async searchReddit(query, dateAfter) {
152
+ if (!query?.trim()) {
153
+ return [];
154
+ }
155
+ let q = query.replace(REDDIT_SITE_REGEX, "").trim() + " site:reddit.com";
156
+ if (dateAfter) {
157
+ q += ` after:${dateAfter}`;
158
+ }
159
+ for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {
160
+ try {
161
+ const res = await fetchWithTimeout(SERPER_API_URL, {
162
+ method: "POST",
163
+ headers: { "X-API-KEY": this.apiKey, "Content-Type": "application/json" },
164
+ body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),
165
+ timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs
166
+ });
167
+ if (!res.ok) {
168
+ if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
169
+ const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
170
+ mcpLog("warning", `Reddit search ${res.status}, retrying in ${delayMs}ms...`, "search");
171
+ await sleep(delayMs);
172
+ continue;
173
+ }
174
+ mcpLog("error", `Reddit search failed with status ${res.status}`, "search");
175
+ return [];
176
+ }
177
+ const data = await res.json();
178
+ return (data.organic || []).map((r) => ({
179
+ title: (r.title || "").replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, "").replace(REDDIT_SUFFIX_REGEX, ""),
180
+ url: r.link || "",
181
+ snippet: r.snippet || "",
182
+ date: r.date
183
+ }));
184
+ } catch (error) {
185
+ const err = classifyError(error);
186
+ if (this.isRetryable(void 0, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {
187
+ const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);
188
+ mcpLog("warning", `Reddit search ${err.code}, retrying in ${delayMs}ms...`, "search");
189
+ await sleep(delayMs);
190
+ continue;
191
+ }
192
+ mcpLog("error", `Reddit search failed: ${err.message}`, "search");
193
+ return [];
194
+ }
195
+ }
196
+ return [];
197
+ }
198
+ /**
199
+ * Search Reddit with multiple queries (bounded concurrency)
200
+ * NEVER throws - searchReddit never throws, pMap preserves order
201
+ */
202
+ async searchRedditMultiple(queries, dateAfter) {
203
+ if (queries.length === 0) {
204
+ return /* @__PURE__ */ new Map();
205
+ }
206
+ const results = await pMap(
207
+ queries,
208
+ (q) => this.searchReddit(q, dateAfter),
209
+ CONCURRENCY.SEARCH
210
+ );
211
+ return new Map(queries.map((q, i) => [q, results[i] || []]));
212
+ }
213
+ }
214
+ export {
215
+ SearchClient
216
+ };
217
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/clients/search.ts"],
4
+ "sourcesContent": ["/**\n * Web Search Client\n * Generic interface for web search via Google (Serper implementation)\n * Implements robust error handling that NEVER crashes\n */\n\nimport { parseEnv, CONCURRENCY } from '../config/index.js';\nimport {\n classifyError,\n fetchWithTimeout,\n sleep,\n ErrorCode,\n type StructuredError,\n} from '../utils/errors.js';\nimport { calculateBackoff } from '../utils/retry.js';\nimport { pMap } from '../utils/concurrency.js';\nimport { mcpLog } from '../utils/logger.js';\n\n// \u2500\u2500 Constants \u2500\u2500\n\nconst SERPER_API_URL = 'https://google.serper.dev/search' as const;\nconst DEFAULT_RESULTS_PER_QUERY = 10 as const;\nconst MAX_RETRIES = 3 as const;\n\n// \u2500\u2500 Data Interfaces \u2500\u2500\n\ninterface SearchResult {\n readonly title: string;\n readonly link: string;\n readonly snippet: string;\n readonly date?: string;\n readonly position: number;\n}\n\nexport interface QuerySearchResult {\n readonly query: string;\n readonly results: SearchResult[];\n readonly totalResults: number;\n readonly related: string[];\n readonly error?: StructuredError;\n}\n\ninterface MultipleSearchResponse {\n readonly searches: QuerySearchResult[];\n readonly totalQueries: number;\n readonly executionTime: number;\n readonly error?: StructuredError;\n}\n\nexport interface RedditSearchResult {\n readonly title: string;\n readonly url: string;\n readonly snippet: string;\n readonly date?: string;\n}\n\n// \u2500\u2500 Retry Configuration \u2500\u2500\n\nconst SEARCH_RETRY_CONFIG = {\n maxRetries: MAX_RETRIES,\n baseDelayMs: 1000,\n maxDelayMs: 10000,\n timeoutMs: 30000,\n} as const;\n\nconst RETRYABLE_SEARCH_CODES = new Set([429, 500, 502, 503, 504]);\n\n// Pre-compiled regex patterns for Reddit search\nconst REDDIT_SITE_REGEX = /site:\\s*reddit\\.com/i;\nconst REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\\/\\w+$/;\nconst REDDIT_SUFFIX_REGEX = / - Reddit$/;\n\n// \u2500\u2500 Helper: Parse Serper search responses into structured results \u2500\u2500\n\nfunction parseSearchResponses(\n responses: Array<Record<string, unknown>>,\n queries: string[],\n): QuerySearchResult[] {\n return responses.map((resp, index) => {\n try {\n const organic = (resp.organic || []) as Array<Record<string, unknown>>;\n const results: SearchResult[] = organic.map((item, idx) => ({\n title: (item.title as string) || 'No title',\n link: (item.link as string) || '#',\n snippet: (item.snippet as string) || '',\n date: item.date as string | undefined,\n position: (item.position as number) || idx + 1,\n }));\n\n const searchInfo = resp.searchInformation as Record<string, unknown> | undefined;\n const totalResults = searchInfo?.totalResults\n ? parseInt(String(searchInfo.totalResults).replace(/,/g, ''), 10)\n : results.length;\n\n const relatedSearches = (resp.relatedSearches || []) as Array<Record<string, unknown>>;\n const related = relatedSearches.map((r) => (r.query as string) || '');\n\n return { query: queries[index] || '', results, totalResults, related };\n } catch {\n return { query: queries[index] || '', results: [], totalResults: 0, related: [] };\n }\n });\n}\n\n// \u2500\u2500 Helper: Execute search API call with retry \u2500\u2500\n\nasync function executeSearchWithRetry(\n apiKey: string,\n body: unknown,\n isRetryable: (status?: number, error?: unknown) => boolean,\n): Promise<{ data: unknown; error?: StructuredError }> {\n let lastError: StructuredError | undefined;\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n if (attempt > 0) {\n mcpLog('warning', `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, 'search');\n }\n\n const response = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: {\n 'X-API-KEY': apiKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!response.ok) {\n const errorText = await response.text().catch(() => '');\n lastError = classifyError({ status: response.status, message: errorText });\n\n if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `API returned ${response.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n\n try {\n const data = await response.json();\n return { data };\n } catch {\n return {\n data: undefined,\n error: { code: ErrorCode.PARSE_ERROR, message: 'Failed to parse search response', retryable: false },\n };\n }\n } catch (error) {\n lastError = classifyError(error);\n\n if (isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n }\n\n return {\n data: undefined,\n error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: 'Search failed', retryable: false },\n };\n}\n\n// \u2500\u2500 SearchClient \u2500\u2500\n\nexport class SearchClient {\n private apiKey: string;\n\n constructor(apiKey?: string) {\n const env = parseEnv();\n this.apiKey = apiKey || env.SEARCH_API_KEY || '';\n\n if (!this.apiKey) {\n throw new Error('Web search capability is not configured. Please set up the required API credentials.');\n }\n }\n\n /**\n * Check if error is retryable\n */\n private isRetryable(status?: number, error?: unknown): boolean {\n if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;\n\n if (error == null) return false;\n const message = (typeof error === 'object' && 'message' in error && typeof (error as { message?: string }).message === 'string')\n ? (error as { message: string }).message.toLowerCase()\n : '';\n return message.includes('timeout') || message.includes('rate limit') || message.includes('connection');\n }\n\n /**\n * Search multiple queries in parallel\n * NEVER throws - always returns a valid response\n */\n async searchMultiple(queries: string[]): Promise<MultipleSearchResponse> {\n const startTime = Date.now();\n\n if (queries.length === 0) {\n return {\n searches: [],\n totalQueries: 0,\n executionTime: 0,\n error: { code: ErrorCode.INVALID_INPUT, message: 'No queries provided', retryable: false },\n };\n }\n\n const searchQueries = queries.map(query => ({ q: query }));\n const { data, error } = await executeSearchWithRetry(\n this.apiKey,\n searchQueries,\n (status, err) => this.isRetryable(status, err),\n );\n\n if (error || data === undefined) {\n return {\n searches: [],\n totalQueries: queries.length,\n executionTime: Date.now() - startTime,\n error,\n };\n }\n\n const responses = Array.isArray(data) ? data : [data];\n const searches = parseSearchResponses(responses as Array<Record<string, unknown>>, queries);\n\n return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };\n }\n\n /**\n * Search Reddit via Google (adds site:reddit.com automatically)\n * NEVER throws - returns empty array on failure\n */\n async searchReddit(query: string, dateAfter?: string): Promise<RedditSearchResult[]> {\n if (!query?.trim()) {\n return [];\n }\n\n let q = query.replace(REDDIT_SITE_REGEX, '').trim() + ' site:reddit.com';\n\n if (dateAfter) {\n q += ` after:${dateAfter}`;\n }\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n const res = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: { 'X-API-KEY': this.apiKey, 'Content-Type': 'application/json' },\n body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!res.ok) {\n if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${res.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed with status ${res.status}`, 'search');\n return [];\n }\n\n const data = await res.json() as { organic?: Array<{ title: string; link: string; snippet: string; date?: string }> };\n return (data.organic || []).map((r) => ({\n title: (r.title || '').replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, '').replace(REDDIT_SUFFIX_REGEX, ''),\n url: r.link || '',\n snippet: r.snippet || '',\n date: r.date,\n }));\n\n } catch (error) {\n const err = classifyError(error);\n if (this.isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${err.code}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed: ${err.message}`, 'search');\n return [];\n }\n }\n\n return [];\n }\n\n /**\n * Search Reddit with multiple queries (bounded concurrency)\n * NEVER throws - searchReddit never throws, pMap preserves order\n */\n async searchRedditMultiple(queries: string[], dateAfter?: string): Promise<Map<string, RedditSearchResult[]>> {\n if (queries.length === 0) {\n return new Map();\n }\n\n const results = await pMap(\n queries,\n q => this.searchReddit(q, dateAfter),\n CONCURRENCY.SEARCH\n );\n\n return new Map(queries.map((q, i) => [q, results[i] || []]));\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }