@duckmind/dm-windows-x64 0.63.0 → 0.63.4

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 (71) hide show
  1. package/dm.exe +0 -0
  2. package/extensions/.dm-extensions.json +138 -2
  3. package/extensions/dm-web-access/SECURITY.md +5 -0
  4. package/extensions/dm-web-access/activity.js +65 -0
  5. package/extensions/dm-web-access/anysearch.js +158 -0
  6. package/extensions/dm-web-access/auth-fetch.js +131 -0
  7. package/extensions/dm-web-access/bocha.js +214 -0
  8. package/extensions/dm-web-access/brave.js +196 -0
  9. package/extensions/dm-web-access/brightdata-unlocker.js +202 -0
  10. package/extensions/dm-web-access/brightdata.js +334 -0
  11. package/extensions/dm-web-access/chrome-cookies.js +627 -0
  12. package/extensions/dm-web-access/content-find.js +114 -0
  13. package/extensions/dm-web-access/credential-source.js +150 -0
  14. package/extensions/dm-web-access/curator-page.js +3559 -0
  15. package/extensions/dm-web-access/curator-server.js +691 -0
  16. package/extensions/dm-web-access/data-uri-sanitize.js +312 -0
  17. package/extensions/dm-web-access/datalab-pdf-extract.js +346 -0
  18. package/extensions/dm-web-access/declared-web-links.js +167 -0
  19. package/extensions/dm-web-access/dm-web-fetch-demo.mp4 +0 -0
  20. package/extensions/dm-web-access/duckduckgo.js +118 -0
  21. package/extensions/dm-web-access/exa.js +401 -0
  22. package/extensions/dm-web-access/extract.js +1220 -0
  23. package/extensions/dm-web-access/feature-config.js +24 -0
  24. package/extensions/dm-web-access/fetch-params.js +81 -0
  25. package/extensions/dm-web-access/firecrawl.js +378 -0
  26. package/extensions/dm-web-access/gemini-adc.js +241 -0
  27. package/extensions/dm-web-access/gemini-api.js +258 -0
  28. package/extensions/dm-web-access/gemini-pdf-extract.js +74 -0
  29. package/extensions/dm-web-access/gemini-search.js +889 -0
  30. package/extensions/dm-web-access/gemini-url-context.js +97 -0
  31. package/extensions/dm-web-access/gemini-web-config.js +84 -0
  32. package/extensions/dm-web-access/gemini-web.js +351 -0
  33. package/extensions/dm-web-access/github-api.js +166 -0
  34. package/extensions/dm-web-access/github-extract.js +991 -0
  35. package/extensions/dm-web-access/github-issue-pr.js +750 -0
  36. package/extensions/dm-web-access/index.js +3117 -0
  37. package/extensions/dm-web-access/jina-search.js +242 -0
  38. package/extensions/dm-web-access/kagi.js +255 -0
  39. package/extensions/dm-web-access/kimi-search.js +214 -0
  40. package/extensions/dm-web-access/ollama.js +209 -0
  41. package/extensions/dm-web-access/openai-search.js +510 -0
  42. package/extensions/dm-web-access/package.json +34 -0
  43. package/extensions/dm-web-access/page-query.js +124 -0
  44. package/extensions/dm-web-access/parallel-mcp.js +223 -0
  45. package/extensions/dm-web-access/parallel.js +345 -0
  46. package/extensions/dm-web-access/pdf-extract.js +257 -0
  47. package/extensions/dm-web-access/perplexity.js +151 -0
  48. package/extensions/dm-web-access/querit.js +327 -0
  49. package/extensions/dm-web-access/query-rewrite.js +40 -0
  50. package/extensions/dm-web-access/render-search-error.js +80 -0
  51. package/extensions/dm-web-access/rsc-extract.js +347 -0
  52. package/extensions/dm-web-access/search1api.js +245 -0
  53. package/extensions/dm-web-access/searchinfinity.js +221 -0
  54. package/extensions/dm-web-access/searxng.js +223 -0
  55. package/extensions/dm-web-access/serpbase.js +205 -0
  56. package/extensions/dm-web-access/serpdive.js +238 -0
  57. package/extensions/dm-web-access/serper.js +200 -0
  58. package/extensions/dm-web-access/source-check.js +198 -0
  59. package/extensions/dm-web-access/ssrf-protection.js +436 -0
  60. package/extensions/dm-web-access/storage.js +451 -0
  61. package/extensions/dm-web-access/summary-model-scope.js +83 -0
  62. package/extensions/dm-web-access/summary-review.js +364 -0
  63. package/extensions/dm-web-access/tavily.js +199 -0
  64. package/extensions/dm-web-access/tinyfish.js +325 -0
  65. package/extensions/dm-web-access/utils.js +476 -0
  66. package/extensions/dm-web-access/valyu.js +189 -0
  67. package/extensions/dm-web-access/video-extract.js +336 -0
  68. package/extensions/dm-web-access/xai-search.js +285 -0
  69. package/extensions/dm-web-access/xcrawl.js +221 -0
  70. package/extensions/dm-web-access/youtube-extract.js +279 -0
  71. package/package.json +9 -1
@@ -0,0 +1,221 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.js";
3
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.js";
4
+ import { getWebSearchConfigPath } from "./utils.js";
5
+ const SEARCHINFINITY_SEARCH_URL = "https://torchlight.byteintlapi.com/search_api/web_search";
6
+ const CONFIG_PATH = getWebSearchConfigPath();
7
+ const SEARCH_TIMEOUT_MS = 30000;
8
+ let cachedConfig = null;
9
+ function loadConfig() {
10
+ if (cachedConfig)
11
+ return cachedConfig;
12
+ if (!existsSync(CONFIG_PATH)) {
13
+ cachedConfig = {};
14
+ return cachedConfig;
15
+ }
16
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ } catch (err) {
21
+ const message = err instanceof Error ? err.message : String(err);
22
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
23
+ }
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
25
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
26
+ }
27
+ cachedConfig = parsed;
28
+ return cachedConfig;
29
+ }
30
+ async function getApiKey(signal) {
31
+ const key = await resolveCredential({
32
+ provider: "Searchinfinity",
33
+ configuredValue: loadConfig().searchinfinityApiKey,
34
+ environmentValue: process.env.SEARCHINFINITY_API_KEY,
35
+ signal
36
+ });
37
+ if (!key) {
38
+ throw new Error(`Searchinfinity API key not found. Either:
39
+ ` + ` 1. Create ${CONFIG_PATH} with { "searchinfinityApiKey": "your-key" }
40
+ ` + ` 2. Set SEARCHINFINITY_API_KEY environment variable
41
+ ` + "Create a key at https://console.byteplus.com/search-infinity/api-key");
42
+ }
43
+ return key;
44
+ }
45
+ export function isSearchinfinityAvailable() {
46
+ return hasCredentialSource({
47
+ provider: "Searchinfinity",
48
+ configuredValue: loadConfig().searchinfinityApiKey,
49
+ environmentValue: process.env.SEARCHINFINITY_API_KEY
50
+ });
51
+ }
52
+ function errorMessage(err) {
53
+ return err instanceof Error ? err.message : String(err);
54
+ }
55
+ function requestSignal(signal, timeoutMs) {
56
+ const timeout = AbortSignal.timeout(timeoutMs);
57
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
58
+ }
59
+ function normalizeNumResults(value) {
60
+ if (typeof value !== "number" || !Number.isFinite(value))
61
+ return 5;
62
+ return Math.max(1, Math.min(Math.floor(value), 20));
63
+ }
64
+ function normalizeDomain(value) {
65
+ let input = value.trim().toLowerCase();
66
+ if (!input)
67
+ return null;
68
+ if (input.startsWith("-"))
69
+ input = input.slice(1).trim();
70
+ if (!input)
71
+ return null;
72
+ try {
73
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
74
+ input = parsed.hostname;
75
+ } catch {
76
+ input = input.split("/")[0]?.split(":")[0] ?? "";
77
+ }
78
+ input = input.replace(/^\.+|\.+$/g, "");
79
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
80
+ }
81
+ function mapRecencyFilter(recency) {
82
+ if (recency === "day")
83
+ return "OneDay";
84
+ if (recency === "week")
85
+ return "OneWeek";
86
+ if (recency === "month")
87
+ return "OneMonth";
88
+ if (recency === "year")
89
+ return "OneYear";
90
+ return;
91
+ }
92
+ function buildSearchBody(query, options) {
93
+ const includeSites = [];
94
+ const blockHosts = [];
95
+ for (const raw of options.domainFilter ?? []) {
96
+ const domain = normalizeDomain(raw);
97
+ if (!domain)
98
+ continue;
99
+ const target = raw.trim().startsWith("-") ? blockHosts : includeSites;
100
+ if (target.length < 5 && !target.includes(domain))
101
+ target.push(domain);
102
+ }
103
+ const filter = {};
104
+ if (includeSites.length > 0)
105
+ filter.Sites = includeSites.join("|");
106
+ if (blockHosts.length > 0)
107
+ filter.BlockHosts = blockHosts.join("|");
108
+ const timeRange = mapRecencyFilter(options.recencyFilter);
109
+ return {
110
+ Query: query,
111
+ Count: normalizeNumResults(options.numResults),
112
+ ...Object.keys(filter).length > 0 ? { Filter: filter } : {},
113
+ ...timeRange ? { TimeRange: timeRange } : {}
114
+ };
115
+ }
116
+ function businessErrorStatus(codeN, code, message) {
117
+ if (codeN === 700901 || code === "invalid_api_key")
118
+ return 401;
119
+ if (codeN === 700429 || code === "700429")
120
+ return 429;
121
+ if (codeN === 10400 || code === "10400")
122
+ return 400;
123
+ if (codeN === 10500 || code === "10500")
124
+ return 500;
125
+ if (codeN === 10403 || code === "10403")
126
+ return /quota|exhaust/i.test(message) ? 429 : 403;
127
+ return;
128
+ }
129
+ async function searchinfinityJsonRequest(apiKey, body, signal) {
130
+ let response;
131
+ try {
132
+ response = await fetch(SEARCHINFINITY_SEARCH_URL, {
133
+ method: "POST",
134
+ headers: {
135
+ Authorization: `Bearer ${apiKey}`,
136
+ "Content-Type": "application/json"
137
+ },
138
+ body: JSON.stringify(body),
139
+ signal: requestSignal(signal, SEARCH_TIMEOUT_MS)
140
+ });
141
+ } catch (err) {
142
+ const message = errorMessage(err);
143
+ const redactedMessage = redactCredential(message, apiKey);
144
+ if (redactedMessage === message)
145
+ throw err;
146
+ const redactedError = new Error(redactedMessage);
147
+ if (err instanceof Error)
148
+ redactedError.name = err.name;
149
+ throw redactedError;
150
+ }
151
+ const raw = await response.text();
152
+ if (!response.ok) {
153
+ throw new Error(`Searchinfinity Search API error ${response.status}: ${redactCredential(raw, apiKey).slice(0, 300)}`);
154
+ }
155
+ let data;
156
+ try {
157
+ data = JSON.parse(raw);
158
+ } catch (err) {
159
+ throw new Error(`Searchinfinity Search API returned invalid JSON: ${errorMessage(err)}`);
160
+ }
161
+ const businessError = data.ResponseMetadata?.Error;
162
+ if (businessError && (businessError.Code || businessError.Message)) {
163
+ const code = typeof businessError.Code === "string" && businessError.Code ? businessError.Code : "unknown";
164
+ const message = typeof businessError.Message === "string" && businessError.Message ? businessError.Message : "unknown error";
165
+ const status = businessErrorStatus(businessError.CodeN, code, message);
166
+ const codeLabel = typeof businessError.CodeN === "number" ? `${businessError.CodeN} ${code}` : code;
167
+ throw new Error(`Searchinfinity Search API error ${status ?? "unknown"}: ${message} (code ${codeLabel})`);
168
+ }
169
+ return data;
170
+ }
171
+ function mapSearchResults(results) {
172
+ if (!Array.isArray(results)) {
173
+ throw new Error("Searchinfinity Search API returned an unexpected response shape");
174
+ }
175
+ return results.flatMap((item) => {
176
+ if (!item || typeof item.Url !== "string" || item.Url.trim().length === 0)
177
+ return [];
178
+ const url = item.Url.trim();
179
+ const summary = typeof item.Summary === "string" ? item.Summary.replace(/\s+/g, " ").trim() : "";
180
+ const snippet = typeof item.Snippet === "string" ? item.Snippet.replace(/\s+/g, " ").trim() : "";
181
+ return [{
182
+ title: typeof item.Title === "string" && item.Title.trim() ? item.Title.trim() : url,
183
+ url,
184
+ snippet: summary || snippet
185
+ }];
186
+ });
187
+ }
188
+ function buildAnswer(results) {
189
+ return results.map((result) => {
190
+ if (result.snippet)
191
+ return `${result.snippet}
192
+ Source: ${result.title} (${result.url})`;
193
+ return `Source: ${result.title} (${result.url})`;
194
+ }).join(`
195
+
196
+ `);
197
+ }
198
+ export async function searchWithSearchinfinity(query, options = {}) {
199
+ const apiKey = await getApiKey(options.signal);
200
+ const activityId = activityMonitor.logStart({ type: "api", query });
201
+ try {
202
+ const data = await searchinfinityJsonRequest(apiKey, buildSearchBody(query, options), options.signal);
203
+ const results = mapSearchResults(data.Result?.WebResults);
204
+ const response = { answer: buildAnswer(results), results };
205
+ activityMonitor.logComplete(activityId, 200);
206
+ return response;
207
+ } catch (err) {
208
+ const message = errorMessage(err);
209
+ const redactedMessage = redactCredential(message, apiKey);
210
+ if (redactedMessage.toLowerCase().includes("abort"))
211
+ activityMonitor.logComplete(activityId, 0);
212
+ else
213
+ activityMonitor.logError(activityId, redactedMessage);
214
+ if (redactedMessage === message)
215
+ throw err;
216
+ const redactedError = new Error(redactedMessage);
217
+ if (err instanceof Error)
218
+ redactedError.name = err.name;
219
+ throw redactedError;
220
+ }
221
+ }
@@ -0,0 +1,223 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.js";
3
+ import { fetchRemoteUrl, loadSsrfConfig } from "./ssrf-protection.js";
4
+ import { getWebSearchConfigPath } from "./utils.js";
5
+ const CONFIG_PATH = getWebSearchConfigPath();
6
+ const SEARCH_TIMEOUT_MS = 30000;
7
+ let cachedConfig = null;
8
+ function loadConfig() {
9
+ if (cachedConfig)
10
+ return cachedConfig;
11
+ if (!existsSync(CONFIG_PATH)) {
12
+ cachedConfig = {};
13
+ return cachedConfig;
14
+ }
15
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
16
+ try {
17
+ cachedConfig = JSON.parse(raw);
18
+ return cachedConfig;
19
+ } catch (err) {
20
+ const message = err instanceof Error ? err.message : String(err);
21
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
22
+ }
23
+ }
24
+ function normalizeBaseUrl(value) {
25
+ if (typeof value !== "string")
26
+ return null;
27
+ const trimmed = value.trim();
28
+ if (!trimmed)
29
+ return null;
30
+ try {
31
+ const url = new URL(trimmed);
32
+ if (url.protocol !== "http:" && url.protocol !== "https:")
33
+ return null;
34
+ if (url.username || url.password)
35
+ return null;
36
+ url.pathname = url.pathname.replace(/\/+$/, "");
37
+ url.search = "";
38
+ url.hash = "";
39
+ return url.toString().replace(/\/+$/, "");
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ function getBaseUrl() {
45
+ const configured = process.env.SEARXNG_BASE_URL;
46
+ return configured !== undefined ? normalizeBaseUrl(configured) : normalizeBaseUrl(loadConfig().searxngBaseUrl);
47
+ }
48
+ function isValidHeaderValue(value) {
49
+ try {
50
+ new Headers({ "x-dm-web-access-validation": value });
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+ function normalizeHeaders(value) {
57
+ if (!value || typeof value !== "object" || Array.isArray(value))
58
+ return {};
59
+ const headers = {};
60
+ for (const [key, headerValue] of Object.entries(value)) {
61
+ if (typeof headerValue !== "string")
62
+ continue;
63
+ const name = key.trim();
64
+ if (!name || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
65
+ continue;
66
+ if (!isValidHeaderValue(headerValue))
67
+ continue;
68
+ headers[name] = headerValue;
69
+ }
70
+ return headers;
71
+ }
72
+ function getConfiguredHeaders() {
73
+ return normalizeHeaders(loadConfig().searxngHeaders);
74
+ }
75
+ function mergeDefaultHeaders(configured) {
76
+ const headers = { Accept: "application/json" };
77
+ for (const [name, value] of Object.entries(configured)) {
78
+ for (const existing of Object.keys(headers)) {
79
+ if (existing.toLowerCase() === name.toLowerCase())
80
+ delete headers[existing];
81
+ }
82
+ headers[name] = value;
83
+ }
84
+ return headers;
85
+ }
86
+ function requireBaseUrl() {
87
+ const baseUrl = getBaseUrl();
88
+ if (!baseUrl) {
89
+ throw new Error(`SearXNG base URL is invalid or missing. Either:
90
+ ` + ` 1. Create ${CONFIG_PATH} with { "searxngBaseUrl": "https://search.example.com" }
91
+ ` + " 2. Set SEARXNG_BASE_URL to an HTTP(S) URL");
92
+ }
93
+ return baseUrl;
94
+ }
95
+ function normalizeCount(value) {
96
+ if (typeof value !== "number" || !Number.isFinite(value))
97
+ return 5;
98
+ return Math.max(1, Math.min(Math.floor(value), 20));
99
+ }
100
+ function normalizeDomain(value) {
101
+ let input = value.trim().toLowerCase();
102
+ if (!input)
103
+ return null;
104
+ if (input.startsWith("-"))
105
+ input = input.slice(1).trim();
106
+ if (!input)
107
+ return null;
108
+ try {
109
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
110
+ input = parsed.hostname;
111
+ } catch {
112
+ input = input.split("/")[0]?.split(":")[0] ?? "";
113
+ }
114
+ input = input.replace(/^\.+|\.+$/g, "");
115
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
116
+ }
117
+ function normalizeDomainFilters(domainFilter) {
118
+ const filters = { allowed: [], blocked: [] };
119
+ for (const raw of domainFilter ?? []) {
120
+ const domain = normalizeDomain(raw);
121
+ if (!domain)
122
+ continue;
123
+ const target = raw.trim().startsWith("-") ? filters.blocked : filters.allowed;
124
+ if (!target.includes(domain))
125
+ target.push(domain);
126
+ }
127
+ return filters;
128
+ }
129
+ function buildSearXNGQuery(query, filters) {
130
+ const parts = [query];
131
+ if (filters.allowed.length === 1) {
132
+ parts.push(`site:${filters.allowed[0]}`);
133
+ } else if (filters.allowed.length > 1) {
134
+ parts.push(filters.allowed.map((domain) => `site:${domain}`).join(" OR "));
135
+ }
136
+ for (const domain of filters.blocked)
137
+ parts.push(`-site:${domain}`);
138
+ return parts.join(" ");
139
+ }
140
+ function hostMatchesDomain(hostname, domain) {
141
+ return hostname === domain || hostname.endsWith(`.${domain}`);
142
+ }
143
+ function matchesDomainFilters(url, filters) {
144
+ if (filters.allowed.length === 0 && filters.blocked.length === 0)
145
+ return true;
146
+ let hostname;
147
+ try {
148
+ hostname = new URL(url).hostname.toLowerCase();
149
+ } catch {
150
+ return false;
151
+ }
152
+ if (filters.allowed.length > 0 && !filters.allowed.some((domain) => hostMatchesDomain(hostname, domain)))
153
+ return false;
154
+ return !filters.blocked.some((domain) => hostMatchesDomain(hostname, domain));
155
+ }
156
+ function mapTimeRange(recencyFilter) {
157
+ return recencyFilter === "day" || recencyFilter === "week" || recencyFilter === "month" || recencyFilter === "year" ? recencyFilter : null;
158
+ }
159
+ export function isSearXNGAvailable() {
160
+ const baseUrl = getBaseUrl();
161
+ if (baseUrl === null)
162
+ return false;
163
+ loadSsrfConfig();
164
+ return true;
165
+ }
166
+ export async function searchWithSearXNG(query, options = {}) {
167
+ const baseUrl = requireBaseUrl();
168
+ const numResults = normalizeCount(options.numResults);
169
+ const filters = normalizeDomainFilters(options.domainFilter);
170
+ const searchQuery = buildSearXNGQuery(query, filters);
171
+ const url = new URL(`${baseUrl}/search`);
172
+ url.searchParams.set("q", searchQuery);
173
+ url.searchParams.set("format", "json");
174
+ const timeRange = mapTimeRange(options.recencyFilter);
175
+ if (timeRange)
176
+ url.searchParams.set("time_range", timeRange);
177
+ const activityId = activityMonitor.logStart({ type: "api", query: searchQuery });
178
+ try {
179
+ const headers = mergeDefaultHeaders(getConfiguredHeaders());
180
+ const response = await fetchRemoteUrl(url, {
181
+ method: "GET",
182
+ headers,
183
+ signal: options.signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS)
184
+ }, {
185
+ ...loadSsrfConfig(),
186
+ onRedirect: ({ from, to, init }) => from.origin === to.origin ? init : { ...init, headers: { Accept: "application/json" } }
187
+ });
188
+ if (!response.ok) {
189
+ activityMonitor.logError(activityId, `HTTP ${response.status}`);
190
+ const errorText = await response.text();
191
+ throw new Error(`SearXNG search error ${response.status}: ${errorText.slice(0, 300)}`);
192
+ }
193
+ let data;
194
+ try {
195
+ data = await response.json();
196
+ } catch (err) {
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ throw new Error(`SearXNG returned invalid JSON: ${message}`);
199
+ }
200
+ activityMonitor.logComplete(activityId, response.status);
201
+ const results = [];
202
+ for (const item of data.results ?? []) {
203
+ if (!item.url || !matchesDomainFilters(item.url, filters))
204
+ continue;
205
+ results.push({ title: item.title || item.url, url: item.url, snippet: item.content || "" });
206
+ if (results.length >= numResults)
207
+ break;
208
+ }
209
+ const answerParts = (data.answers ?? []).filter((answer) => typeof answer === "string" && answer.trim().length > 0).map((answer) => answer.trim());
210
+ answerParts.push(...results.map((result) => result.snippet ? `${result.snippet}
211
+ Source: ${result.title} (${result.url})` : `Source: ${result.title} (${result.url})`));
212
+ return { answer: answerParts.join(`
213
+
214
+ `), results };
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ if (message.toLowerCase().includes("abort"))
218
+ activityMonitor.logComplete(activityId, 0);
219
+ else
220
+ activityMonitor.logError(activityId, message);
221
+ throw err;
222
+ }
223
+ }
@@ -0,0 +1,205 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.js";
3
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.js";
4
+ import { getWebSearchConfigPath } from "./utils.js";
5
+ const SERPBASE_API_URL = "https://api.serpbase.dev/google/search";
6
+ const CONFIG_PATH = getWebSearchConfigPath();
7
+ const SEARCH_TIMEOUT_MS = 60000;
8
+ const RECENCY_TBS = {
9
+ day: "qdr:d",
10
+ week: "qdr:w",
11
+ month: "qdr:m",
12
+ year: "qdr:y"
13
+ };
14
+ let cachedConfig = null;
15
+ function loadConfig() {
16
+ if (cachedConfig)
17
+ return cachedConfig;
18
+ if (!existsSync(CONFIG_PATH)) {
19
+ cachedConfig = {};
20
+ return cachedConfig;
21
+ }
22
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ } catch (err) {
27
+ const message = err instanceof Error ? err.message : String(err);
28
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
32
+ }
33
+ cachedConfig = parsed;
34
+ return cachedConfig;
35
+ }
36
+ async function getApiKey(signal) {
37
+ return resolveCredential({
38
+ provider: "SerpBase",
39
+ configuredValue: loadConfig().serpbaseApiKey,
40
+ environmentValue: process.env.SERPBASE_API_KEY,
41
+ signal
42
+ });
43
+ }
44
+ async function requireApiKey(signal) {
45
+ const apiKey = await getApiKey(signal);
46
+ if (!apiKey) {
47
+ throw new Error(`SerpBase API key not found. Either:
48
+ ` + ` 1. Create ${CONFIG_PATH} with { "serpbaseApiKey": "your-key" }
49
+ ` + ` 2. Set SERPBASE_API_KEY environment variable
50
+ ` + "Get a key at https://serpbase.dev");
51
+ }
52
+ return apiKey;
53
+ }
54
+ function normalizeCount(value) {
55
+ if (typeof value !== "number" || !Number.isFinite(value))
56
+ return 10;
57
+ return Math.max(1, Math.min(Math.floor(value), 20));
58
+ }
59
+ function normalizeDomain(value) {
60
+ let input = value.trim().toLowerCase();
61
+ if (!input)
62
+ return null;
63
+ if (input.startsWith("-"))
64
+ input = input.slice(1).trim();
65
+ if (!input)
66
+ return null;
67
+ try {
68
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
69
+ input = parsed.hostname;
70
+ } catch {
71
+ input = input.split("/")[0]?.split(":")[0] ?? "";
72
+ }
73
+ input = input.replace(/^\.+|\.+$/g, "");
74
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
75
+ }
76
+ function parseDomainFilter(domainFilter) {
77
+ const filters = { include: [], exclude: [] };
78
+ if (!domainFilter?.length)
79
+ return filters;
80
+ for (const raw of domainFilter) {
81
+ const domain = normalizeDomain(raw);
82
+ if (!domain)
83
+ continue;
84
+ const target = raw.trim().startsWith("-") ? filters.exclude : filters.include;
85
+ if (!target.includes(domain))
86
+ target.push(domain);
87
+ }
88
+ return filters;
89
+ }
90
+ function domainMatches(hostname, domain) {
91
+ return hostname === domain || hostname.endsWith(`.${domain}`);
92
+ }
93
+ function passesDomainFilters(url, filters) {
94
+ if (filters.include.length === 0 && filters.exclude.length === 0)
95
+ return true;
96
+ let hostname;
97
+ try {
98
+ hostname = new URL(url).hostname.toLowerCase();
99
+ } catch {
100
+ return false;
101
+ }
102
+ if (filters.exclude.some((domain) => domainMatches(hostname, domain)))
103
+ return false;
104
+ if (filters.include.length === 0)
105
+ return true;
106
+ return filters.include.some((domain) => domainMatches(hostname, domain));
107
+ }
108
+ function buildQuery(query, filters) {
109
+ const parts = [query];
110
+ if (filters.include.length === 1)
111
+ parts.push(`site:${filters.include[0]}`);
112
+ if (filters.include.length > 1)
113
+ parts.push(`(${filters.include.map((domain) => `site:${domain}`).join(" OR ")})`);
114
+ for (const domain of filters.exclude)
115
+ parts.push(`-site:${domain}`);
116
+ return parts.join(" ");
117
+ }
118
+ function errorMessage(err) {
119
+ return err instanceof Error ? err.message : String(err);
120
+ }
121
+ function invalidResponse(message) {
122
+ return new Error(`SerpBase API returned invalid response: ${message}`);
123
+ }
124
+ function parseResponse(value) {
125
+ if (!value || typeof value !== "object" || Array.isArray(value))
126
+ throw invalidResponse("expected an object envelope");
127
+ const envelope = value;
128
+ if (typeof envelope.error === "string" && envelope.error.trim()) {
129
+ const suffix = typeof envelope.status === "number" || typeof envelope.status === "string" ? ` (status ${envelope.status})` : "";
130
+ throw invalidResponse(`${envelope.error}${suffix}`);
131
+ }
132
+ const organic = envelope.organic_results ?? envelope.organic ?? envelope.results;
133
+ if (!Array.isArray(organic))
134
+ throw invalidResponse("expected organic_results array");
135
+ return { ...envelope, organic_results: organic };
136
+ }
137
+ function buildAnswer(results) {
138
+ return results.map((result) => result.snippet ? `${result.snippet}
139
+ Source: ${result.title} (${result.url})` : `Source: ${result.title} (${result.url})`).join(`
140
+
141
+ `);
142
+ }
143
+ export function isSerpBaseAvailable() {
144
+ return hasCredentialSource({ provider: "SerpBase", configuredValue: loadConfig().serpbaseApiKey, environmentValue: process.env.SERPBASE_API_KEY });
145
+ }
146
+ export async function searchWithSerpBase(query, options = {}) {
147
+ const apiKey = await requireApiKey(options.signal);
148
+ const numResults = normalizeCount(options.numResults);
149
+ const filters = parseDomainFilter(options.domainFilter);
150
+ const url = new URL(SERPBASE_API_URL);
151
+ url.searchParams.set("q", buildQuery(query, filters));
152
+ url.searchParams.set("api_key", apiKey);
153
+ url.searchParams.set("num", String(numResults));
154
+ if (options.recencyFilter && RECENCY_TBS[options.recencyFilter])
155
+ url.searchParams.set("tbs", RECENCY_TBS[options.recencyFilter]);
156
+ const activityId = activityMonitor.logStart({ type: "api", query });
157
+ let response;
158
+ try {
159
+ response = await fetch(url, {
160
+ headers: { Accept: "application/json" },
161
+ signal: options.signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS)
162
+ });
163
+ } catch (err) {
164
+ const message = errorMessage(err);
165
+ const redactedMessage = redactCredential(message, apiKey);
166
+ if (redactedMessage.toLowerCase().includes("abort"))
167
+ activityMonitor.logComplete(activityId, 0);
168
+ else
169
+ activityMonitor.logError(activityId, redactedMessage);
170
+ if (redactedMessage === message)
171
+ throw err;
172
+ const redactedError = new Error(redactedMessage);
173
+ if (err instanceof Error)
174
+ redactedError.name = err.name;
175
+ throw redactedError;
176
+ }
177
+ if (!response.ok) {
178
+ activityMonitor.logComplete(activityId, response.status);
179
+ const errorText = redactCredential(await response.text(), apiKey);
180
+ throw new Error(`SerpBase API error ${response.status}: ${errorText.slice(0, 300)}`);
181
+ }
182
+ let rawData;
183
+ try {
184
+ rawData = await response.json();
185
+ } catch (err) {
186
+ activityMonitor.logComplete(activityId, response.status);
187
+ throw new Error(`SerpBase API returned invalid JSON: ${errorMessage(err)}`);
188
+ }
189
+ const data = parseResponse(rawData);
190
+ activityMonitor.logComplete(activityId, response.status);
191
+ const results = [];
192
+ for (const item of data.organic_results ?? []) {
193
+ const url = typeof item.link === "string" ? item.link : typeof item.url === "string" ? item.url : "";
194
+ if (!url || !passesDomainFilters(url, filters))
195
+ continue;
196
+ results.push({
197
+ title: typeof item.title === "string" && item.title.trim() ? item.title : `Source ${results.length + 1}`,
198
+ url,
199
+ snippet: typeof item.snippet === "string" ? item.snippet : typeof item.description === "string" ? item.description : ""
200
+ });
201
+ if (results.length >= numResults)
202
+ break;
203
+ }
204
+ return { answer: buildAnswer(results), results };
205
+ }