@duckmind/dm-windows-x64 0.62.9 → 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.
- package/dm.exe +0 -0
- package/extensions/.dm-extensions.json +140 -4
- package/extensions/dm-ponytail/extensions/ponytail.mjs +1 -1
- package/extensions/dm-web-access/SECURITY.md +5 -0
- package/extensions/dm-web-access/activity.js +65 -0
- package/extensions/dm-web-access/anysearch.js +158 -0
- package/extensions/dm-web-access/auth-fetch.js +131 -0
- package/extensions/dm-web-access/bocha.js +214 -0
- package/extensions/dm-web-access/brave.js +196 -0
- package/extensions/dm-web-access/brightdata-unlocker.js +202 -0
- package/extensions/dm-web-access/brightdata.js +334 -0
- package/extensions/dm-web-access/chrome-cookies.js +627 -0
- package/extensions/dm-web-access/content-find.js +114 -0
- package/extensions/dm-web-access/credential-source.js +150 -0
- package/extensions/dm-web-access/curator-page.js +3559 -0
- package/extensions/dm-web-access/curator-server.js +691 -0
- package/extensions/dm-web-access/data-uri-sanitize.js +312 -0
- package/extensions/dm-web-access/datalab-pdf-extract.js +346 -0
- package/extensions/dm-web-access/declared-web-links.js +167 -0
- package/extensions/dm-web-access/dm-web-fetch-demo.mp4 +0 -0
- package/extensions/dm-web-access/duckduckgo.js +118 -0
- package/extensions/dm-web-access/exa.js +401 -0
- package/extensions/dm-web-access/extract.js +1220 -0
- package/extensions/dm-web-access/feature-config.js +24 -0
- package/extensions/dm-web-access/fetch-params.js +81 -0
- package/extensions/dm-web-access/firecrawl.js +378 -0
- package/extensions/dm-web-access/gemini-adc.js +241 -0
- package/extensions/dm-web-access/gemini-api.js +258 -0
- package/extensions/dm-web-access/gemini-pdf-extract.js +74 -0
- package/extensions/dm-web-access/gemini-search.js +889 -0
- package/extensions/dm-web-access/gemini-url-context.js +97 -0
- package/extensions/dm-web-access/gemini-web-config.js +84 -0
- package/extensions/dm-web-access/gemini-web.js +351 -0
- package/extensions/dm-web-access/github-api.js +166 -0
- package/extensions/dm-web-access/github-extract.js +991 -0
- package/extensions/dm-web-access/github-issue-pr.js +750 -0
- package/extensions/dm-web-access/index.js +3117 -0
- package/extensions/dm-web-access/jina-search.js +242 -0
- package/extensions/dm-web-access/kagi.js +255 -0
- package/extensions/dm-web-access/kimi-search.js +214 -0
- package/extensions/dm-web-access/ollama.js +209 -0
- package/extensions/dm-web-access/openai-search.js +510 -0
- package/extensions/dm-web-access/package.json +34 -0
- package/extensions/dm-web-access/page-query.js +124 -0
- package/extensions/dm-web-access/parallel-mcp.js +223 -0
- package/extensions/dm-web-access/parallel.js +345 -0
- package/extensions/dm-web-access/pdf-extract.js +257 -0
- package/extensions/dm-web-access/perplexity.js +151 -0
- package/extensions/dm-web-access/querit.js +327 -0
- package/extensions/dm-web-access/query-rewrite.js +40 -0
- package/extensions/dm-web-access/render-search-error.js +80 -0
- package/extensions/dm-web-access/rsc-extract.js +347 -0
- package/extensions/dm-web-access/search1api.js +245 -0
- package/extensions/dm-web-access/searchinfinity.js +221 -0
- package/extensions/dm-web-access/searxng.js +223 -0
- package/extensions/dm-web-access/serpbase.js +205 -0
- package/extensions/dm-web-access/serpdive.js +238 -0
- package/extensions/dm-web-access/serper.js +200 -0
- package/extensions/dm-web-access/source-check.js +198 -0
- package/extensions/dm-web-access/ssrf-protection.js +436 -0
- package/extensions/dm-web-access/storage.js +451 -0
- package/extensions/dm-web-access/summary-model-scope.js +83 -0
- package/extensions/dm-web-access/summary-review.js +364 -0
- package/extensions/dm-web-access/tavily.js +199 -0
- package/extensions/dm-web-access/tinyfish.js +325 -0
- package/extensions/dm-web-access/utils.js +476 -0
- package/extensions/dm-web-access/valyu.js +189 -0
- package/extensions/dm-web-access/video-extract.js +336 -0
- package/extensions/dm-web-access/xai-search.js +285 -0
- package/extensions/dm-web-access/xcrawl.js +221 -0
- package/extensions/dm-web-access/youtube-extract.js +279 -0
- package/package.json +9 -1
|
@@ -0,0 +1,214 @@
|
|
|
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 BOCHA_SEARCH_URL = "https://api.bochaai.com/v1/web-search";
|
|
6
|
+
const CONFIG_PATH = getWebSearchConfigPath();
|
|
7
|
+
const SEARCH_TIMEOUT_MS = 60000;
|
|
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
|
+
return resolveCredential({
|
|
32
|
+
provider: "Bocha",
|
|
33
|
+
configuredValue: loadConfig().bochaApiKey,
|
|
34
|
+
environmentValue: process.env.BOCHA_API_KEY,
|
|
35
|
+
signal
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function requireApiKey(signal) {
|
|
39
|
+
const apiKey = await getApiKey(signal);
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
throw new Error(`Bocha API key not found. Either:
|
|
42
|
+
` + ` 1. Create ${CONFIG_PATH} with { "bochaApiKey": "your-key" }
|
|
43
|
+
` + ` 2. Set BOCHA_API_KEY environment variable
|
|
44
|
+
` + "Create a key at https://open.bochaai.com/");
|
|
45
|
+
}
|
|
46
|
+
return apiKey;
|
|
47
|
+
}
|
|
48
|
+
function normalizeCount(value) {
|
|
49
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
50
|
+
return 8;
|
|
51
|
+
return Math.max(1, Math.min(Math.floor(value), 20));
|
|
52
|
+
}
|
|
53
|
+
function mapFreshness(value) {
|
|
54
|
+
switch (value) {
|
|
55
|
+
case "day":
|
|
56
|
+
return "oneDay";
|
|
57
|
+
case "week":
|
|
58
|
+
return "oneWeek";
|
|
59
|
+
case "month":
|
|
60
|
+
return "oneMonth";
|
|
61
|
+
case "year":
|
|
62
|
+
return "oneYear";
|
|
63
|
+
default:
|
|
64
|
+
return "noLimit";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function normalizeDomain(value) {
|
|
68
|
+
let input = value.trim().toLowerCase();
|
|
69
|
+
if (!input)
|
|
70
|
+
return null;
|
|
71
|
+
if (input.startsWith("-"))
|
|
72
|
+
input = input.slice(1).trim();
|
|
73
|
+
if (!input)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
|
|
77
|
+
input = parsed.hostname;
|
|
78
|
+
} catch {
|
|
79
|
+
input = input.split("/")[0]?.split(":")[0] ?? "";
|
|
80
|
+
}
|
|
81
|
+
input = input.replace(/^\.+|\.+$/g, "");
|
|
82
|
+
return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
|
|
83
|
+
}
|
|
84
|
+
function parseDomainFilter(domainFilter) {
|
|
85
|
+
const filters = { include: [], exclude: [] };
|
|
86
|
+
for (const raw of domainFilter ?? []) {
|
|
87
|
+
const domain = normalizeDomain(raw);
|
|
88
|
+
if (!domain)
|
|
89
|
+
continue;
|
|
90
|
+
const target = raw.trim().startsWith("-") ? filters.exclude : filters.include;
|
|
91
|
+
if (!target.includes(domain))
|
|
92
|
+
target.push(domain);
|
|
93
|
+
}
|
|
94
|
+
return filters;
|
|
95
|
+
}
|
|
96
|
+
function passesDomainFilters(url, filters) {
|
|
97
|
+
if (filters.include.length === 0 && filters.exclude.length === 0)
|
|
98
|
+
return true;
|
|
99
|
+
let hostname;
|
|
100
|
+
try {
|
|
101
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const matches = (domain) => hostname === domain || hostname.endsWith(`.${domain}`);
|
|
106
|
+
if (filters.exclude.some(matches))
|
|
107
|
+
return false;
|
|
108
|
+
return filters.include.length === 0 || filters.include.some(matches);
|
|
109
|
+
}
|
|
110
|
+
function errorMessage(err) {
|
|
111
|
+
return err instanceof Error ? err.message : String(err);
|
|
112
|
+
}
|
|
113
|
+
function invalidResponse(message) {
|
|
114
|
+
return new Error(`Bocha API returned invalid response: ${message}`);
|
|
115
|
+
}
|
|
116
|
+
function firstString(...values) {
|
|
117
|
+
for (const value of values) {
|
|
118
|
+
if (typeof value === "string" && value.trim())
|
|
119
|
+
return value.trim();
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function parseSearchResponse(value) {
|
|
124
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
125
|
+
throw invalidResponse("expected an object envelope");
|
|
126
|
+
const envelope = value;
|
|
127
|
+
if (envelope.code !== undefined && Number(envelope.code) !== 200) {
|
|
128
|
+
throw invalidResponse(`code ${String(envelope.code)}: ${firstString(envelope.msg) ?? "unknown error"}`);
|
|
129
|
+
}
|
|
130
|
+
const data = envelope.data;
|
|
131
|
+
const pages = typeof data === "object" && data !== null && !Array.isArray(data) ? data.webPages : undefined;
|
|
132
|
+
const items = typeof pages === "object" && pages !== null && !Array.isArray(pages) ? pages.value : undefined;
|
|
133
|
+
if (!Array.isArray(items))
|
|
134
|
+
throw invalidResponse("missing data.webPages.value array");
|
|
135
|
+
const results = [];
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
138
|
+
continue;
|
|
139
|
+
const entry = item;
|
|
140
|
+
const url = firstString(entry.url, entry.link, entry.href);
|
|
141
|
+
if (!url)
|
|
142
|
+
continue;
|
|
143
|
+
const title = firstString(entry.title, entry.name) ?? url;
|
|
144
|
+
const snippet = firstString(entry.summary, entry.snippet, entry.description, entry.content) ?? "";
|
|
145
|
+
results.push({ title, url, snippet });
|
|
146
|
+
}
|
|
147
|
+
return { results };
|
|
148
|
+
}
|
|
149
|
+
function buildAnswer(results) {
|
|
150
|
+
return results.map((result) => result.snippet ? `${result.snippet}
|
|
151
|
+
Source: ${result.title} (${result.url})` : `Source: ${result.title} (${result.url})`).join(`
|
|
152
|
+
|
|
153
|
+
`);
|
|
154
|
+
}
|
|
155
|
+
export function isBochaAvailable() {
|
|
156
|
+
return hasCredentialSource({ provider: "Bocha", configuredValue: loadConfig().bochaApiKey, environmentValue: process.env.BOCHA_API_KEY });
|
|
157
|
+
}
|
|
158
|
+
export async function searchWithBocha(query, options = {}) {
|
|
159
|
+
const apiKey = await requireApiKey(options.signal);
|
|
160
|
+
const numResults = normalizeCount(options.numResults);
|
|
161
|
+
const filters = parseDomainFilter(options.domainFilter);
|
|
162
|
+
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await fetch(BOCHA_SEARCH_URL, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
168
|
+
body: JSON.stringify({ query, count: numResults, freshness: mapFreshness(options.recencyFilter), summary: true }),
|
|
169
|
+
signal: options.signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS)
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const message = errorMessage(err);
|
|
173
|
+
const redactedMessage = redactCredential(message, apiKey);
|
|
174
|
+
if (redactedMessage.toLowerCase().includes("abort"))
|
|
175
|
+
activityMonitor.logComplete(activityId, 0);
|
|
176
|
+
else
|
|
177
|
+
activityMonitor.logError(activityId, redactedMessage);
|
|
178
|
+
if (redactedMessage === message)
|
|
179
|
+
throw err;
|
|
180
|
+
const redactedError = new Error(redactedMessage);
|
|
181
|
+
if (err instanceof Error)
|
|
182
|
+
redactedError.name = err.name;
|
|
183
|
+
throw redactedError;
|
|
184
|
+
}
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
187
|
+
const errorText = redactCredential(await response.text(), apiKey);
|
|
188
|
+
throw new Error(`Bocha API error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
189
|
+
}
|
|
190
|
+
let rawData;
|
|
191
|
+
try {
|
|
192
|
+
rawData = await response.json();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
195
|
+
throw new Error(`Bocha API returned invalid JSON: ${errorMessage(err)}`);
|
|
196
|
+
}
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = parseSearchResponse(rawData);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const message = errorMessage(err);
|
|
202
|
+
const redactedMessage = redactCredential(message, apiKey);
|
|
203
|
+
activityMonitor.logError(activityId, redactedMessage);
|
|
204
|
+
if (redactedMessage === message)
|
|
205
|
+
throw err;
|
|
206
|
+
const redactedError = new Error(redactedMessage);
|
|
207
|
+
if (err instanceof Error)
|
|
208
|
+
redactedError.name = err.name;
|
|
209
|
+
throw redactedError;
|
|
210
|
+
}
|
|
211
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
212
|
+
const results = parsed.results.filter((result) => passesDomainFilters(result.url, filters)).slice(0, numResults);
|
|
213
|
+
return { answer: buildAnswer(results), results };
|
|
214
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { activityMonitor } from "./activity.js";
|
|
3
|
+
import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.js";
|
|
4
|
+
import { fetchWithCredentialRedirects, getWebSearchConfigPath, resolveApiBaseUrl } from "./utils.js";
|
|
5
|
+
const BRAVE_API_BASE_URL = "https://api.search.brave.com/res/v1";
|
|
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
|
+
try {
|
|
18
|
+
cachedConfig = JSON.parse(raw);
|
|
19
|
+
return cachedConfig;
|
|
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
|
+
}
|
|
25
|
+
async function getApiKey(signal) {
|
|
26
|
+
return resolveCredential({
|
|
27
|
+
provider: "Brave",
|
|
28
|
+
configuredValue: loadConfig().braveApiKey,
|
|
29
|
+
environmentValue: process.env.BRAVE_API_KEY,
|
|
30
|
+
signal
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function getApiUrl() {
|
|
34
|
+
return `${resolveApiBaseUrl({
|
|
35
|
+
configKey: "braveBaseUrl",
|
|
36
|
+
configuredValue: loadConfig().braveBaseUrl,
|
|
37
|
+
defaultValue: BRAVE_API_BASE_URL,
|
|
38
|
+
environmentKey: "BRAVE_BASE_URL",
|
|
39
|
+
environmentValue: process.env.BRAVE_BASE_URL
|
|
40
|
+
})}/web/search`;
|
|
41
|
+
}
|
|
42
|
+
function normalizeCount(value) {
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
44
|
+
return 5;
|
|
45
|
+
return Math.max(1, Math.min(Math.floor(value), 20));
|
|
46
|
+
}
|
|
47
|
+
function normalizeDomain(value) {
|
|
48
|
+
let input = value.trim().toLowerCase();
|
|
49
|
+
if (!input)
|
|
50
|
+
return null;
|
|
51
|
+
if (input.startsWith("-"))
|
|
52
|
+
input = input.slice(1).trim();
|
|
53
|
+
if (!input)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
|
|
57
|
+
input = parsed.hostname;
|
|
58
|
+
} catch {
|
|
59
|
+
input = input.split("/")[0]?.split(":")[0] ?? "";
|
|
60
|
+
}
|
|
61
|
+
input = input.replace(/^\.+|\.+$/g, "");
|
|
62
|
+
return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
|
|
63
|
+
}
|
|
64
|
+
function normalizeDomainFilters(domainFilter) {
|
|
65
|
+
const filters = { allowed: [], blocked: [] };
|
|
66
|
+
if (!domainFilter?.length)
|
|
67
|
+
return filters;
|
|
68
|
+
for (const raw of domainFilter) {
|
|
69
|
+
const domain = normalizeDomain(raw);
|
|
70
|
+
if (!domain)
|
|
71
|
+
continue;
|
|
72
|
+
const target = raw.trim().startsWith("-") ? filters.blocked : filters.allowed;
|
|
73
|
+
if (!target.includes(domain))
|
|
74
|
+
target.push(domain);
|
|
75
|
+
}
|
|
76
|
+
return filters;
|
|
77
|
+
}
|
|
78
|
+
function buildBraveQuery(query, domainFilter) {
|
|
79
|
+
const filters = normalizeDomainFilters(domainFilter);
|
|
80
|
+
const parts = [query];
|
|
81
|
+
if (filters.allowed.length === 1) {
|
|
82
|
+
parts.push(`site:${filters.allowed[0]}`);
|
|
83
|
+
} else if (filters.allowed.length > 1) {
|
|
84
|
+
parts.push(filters.allowed.map((domain) => `site:${domain}`).join(" OR "));
|
|
85
|
+
}
|
|
86
|
+
for (const domain of filters.blocked) {
|
|
87
|
+
parts.push(`NOT site:${domain}`);
|
|
88
|
+
}
|
|
89
|
+
return parts.join(" ");
|
|
90
|
+
}
|
|
91
|
+
function hostMatchesDomain(hostname, domain) {
|
|
92
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
93
|
+
}
|
|
94
|
+
function matchesDomainFilters(url, filters) {
|
|
95
|
+
if (filters.allowed.length === 0 && filters.blocked.length === 0)
|
|
96
|
+
return true;
|
|
97
|
+
let hostname = "";
|
|
98
|
+
try {
|
|
99
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (filters.allowed.length > 0 && !filters.allowed.some((domain) => hostMatchesDomain(hostname, domain))) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return !filters.blocked.some((domain) => hostMatchesDomain(hostname, domain));
|
|
107
|
+
}
|
|
108
|
+
export function isBraveAvailable() {
|
|
109
|
+
return hasCredentialSource({
|
|
110
|
+
provider: "Brave",
|
|
111
|
+
configuredValue: loadConfig().braveApiKey,
|
|
112
|
+
environmentValue: process.env.BRAVE_API_KEY
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export async function searchWithBrave(query, options = {}) {
|
|
116
|
+
const apiUrl = getApiUrl();
|
|
117
|
+
const apiKey = await getApiKey(options.signal);
|
|
118
|
+
if (!apiKey) {
|
|
119
|
+
throw new Error(`Brave Search API key not found. Either:
|
|
120
|
+
` + ` 1. Create ${CONFIG_PATH} with { "braveApiKey": "your-key" }
|
|
121
|
+
` + ` 2. Set BRAVE_API_KEY environment variable
|
|
122
|
+
` + "Get a key at https://brave.com/search/api/");
|
|
123
|
+
}
|
|
124
|
+
const numResults = normalizeCount(options.numResults);
|
|
125
|
+
const domainFilters = normalizeDomainFilters(options.domainFilter);
|
|
126
|
+
const searchQuery = buildBraveQuery(query, options.domainFilter);
|
|
127
|
+
const activityId = activityMonitor.logStart({ type: "api", query: searchQuery });
|
|
128
|
+
const params = new URLSearchParams({
|
|
129
|
+
q: searchQuery,
|
|
130
|
+
count: String(options.domainFilter?.length ? 20 : numResults)
|
|
131
|
+
});
|
|
132
|
+
if (options.recencyFilter) {
|
|
133
|
+
const freshnessMap = {
|
|
134
|
+
day: "pd",
|
|
135
|
+
week: "pw",
|
|
136
|
+
month: "pm",
|
|
137
|
+
year: "py"
|
|
138
|
+
};
|
|
139
|
+
const freshness = freshnessMap[options.recencyFilter];
|
|
140
|
+
if (freshness)
|
|
141
|
+
params.set("freshness", freshness);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetchWithCredentialRedirects(`${apiUrl}?${params.toString()}`, {
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: {
|
|
147
|
+
"X-Subscription-Token": apiKey,
|
|
148
|
+
Accept: "application/json",
|
|
149
|
+
"Accept-Encoding": "gzip"
|
|
150
|
+
},
|
|
151
|
+
signal: options.signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS)
|
|
152
|
+
}, ["X-Subscription-Token"]);
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
activityMonitor.logError(activityId, `HTTP ${response.status}`);
|
|
155
|
+
const errorText = redactCredential(await response.text(), apiKey);
|
|
156
|
+
throw new Error(`Brave Search API error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
157
|
+
}
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
160
|
+
const results = [];
|
|
161
|
+
for (const item of data.web?.results ?? []) {
|
|
162
|
+
if (!item.url || !matchesDomainFilters(item.url, domainFilters))
|
|
163
|
+
continue;
|
|
164
|
+
results.push({
|
|
165
|
+
title: item.title || item.url,
|
|
166
|
+
url: item.url,
|
|
167
|
+
snippet: item.description || ""
|
|
168
|
+
});
|
|
169
|
+
if (results.length >= numResults)
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const answer = results.map((result) => {
|
|
173
|
+
if (result.snippet)
|
|
174
|
+
return `${result.snippet}
|
|
175
|
+
Source: ${result.title} (${result.url})`;
|
|
176
|
+
return `Source: ${result.title} (${result.url})`;
|
|
177
|
+
}).join(`
|
|
178
|
+
|
|
179
|
+
`);
|
|
180
|
+
return { answer, results };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
183
|
+
const redactedMessage = redactCredential(message, apiKey);
|
|
184
|
+
if (redactedMessage.toLowerCase().includes("abort")) {
|
|
185
|
+
activityMonitor.logComplete(activityId, 0);
|
|
186
|
+
} else {
|
|
187
|
+
activityMonitor.logError(activityId, redactedMessage);
|
|
188
|
+
}
|
|
189
|
+
if (redactedMessage === message)
|
|
190
|
+
throw err;
|
|
191
|
+
const redactedError = new Error(redactedMessage);
|
|
192
|
+
if (err instanceof Error)
|
|
193
|
+
redactedError.name = err.name;
|
|
194
|
+
throw redactedError;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { activityMonitor } from "./activity.js";
|
|
3
|
+
import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.js";
|
|
4
|
+
import { validateRemoteUrl } from "./ssrf-protection.js";
|
|
5
|
+
import { getWebSearchConfigPath } from "./utils.js";
|
|
6
|
+
const CONFIG_PATH = getWebSearchConfigPath();
|
|
7
|
+
const BRIGHTDATA_REQUEST_URL = "https://api.brightdata.com/request";
|
|
8
|
+
const EXTRACT_TIMEOUT_MS = 60000;
|
|
9
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
10
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
11
|
+
const ZONE_PATTERN = /^[a-z0-9_-]+$/i;
|
|
12
|
+
let cachedConfig = null;
|
|
13
|
+
function parseFailureDetail(err) {
|
|
14
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
15
|
+
const position = message.match(/at position \d+(?: \(line \d+ column \d+\))?/);
|
|
16
|
+
return position ? `invalid JSON ${position[0]}` : "invalid JSON";
|
|
17
|
+
}
|
|
18
|
+
function loadConfig() {
|
|
19
|
+
if (cachedConfig)
|
|
20
|
+
return cachedConfig;
|
|
21
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
22
|
+
cachedConfig = {};
|
|
23
|
+
return cachedConfig;
|
|
24
|
+
}
|
|
25
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${parseFailureDetail(err)}`);
|
|
31
|
+
}
|
|
32
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
33
|
+
throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
|
|
34
|
+
}
|
|
35
|
+
cachedConfig = parsed;
|
|
36
|
+
return cachedConfig;
|
|
37
|
+
}
|
|
38
|
+
export function clearBrightDataUnlockerConfigCache() {
|
|
39
|
+
cachedConfig = null;
|
|
40
|
+
}
|
|
41
|
+
function normalizeZone(value) {
|
|
42
|
+
if (typeof value !== "string")
|
|
43
|
+
return null;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (!trimmed)
|
|
46
|
+
return null;
|
|
47
|
+
return ZONE_PATTERN.test(trimmed) ? trimmed : null;
|
|
48
|
+
}
|
|
49
|
+
function zoneSetting() {
|
|
50
|
+
const fromEnv = process.env.BRIGHTDATA_UNLOCKER_ZONE;
|
|
51
|
+
if (typeof fromEnv === "string" && fromEnv.trim())
|
|
52
|
+
return { raw: fromEnv.trim(), label: "BRIGHTDATA_UNLOCKER_ZONE" };
|
|
53
|
+
const configured = loadConfig().brightdataUnlockerZone;
|
|
54
|
+
if (typeof configured === "string" && configured.trim())
|
|
55
|
+
return { raw: configured.trim(), label: `brightdataUnlockerZone in ${CONFIG_PATH}` };
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function getZone() {
|
|
59
|
+
const setting = zoneSetting();
|
|
60
|
+
return setting ? normalizeZone(setting.raw) : null;
|
|
61
|
+
}
|
|
62
|
+
function requireZone() {
|
|
63
|
+
const setting = zoneSetting();
|
|
64
|
+
const zone = setting ? normalizeZone(setting.raw) : null;
|
|
65
|
+
if (zone)
|
|
66
|
+
return zone;
|
|
67
|
+
if (setting) {
|
|
68
|
+
throw new Error(`Invalid Bright Data Unlocker zone: ${setting.label} must be a zone name of letters, digits, "-", or "_". ` + 'The zone must be of type "unblocker"; a SERP zone will not serve Web Unlocker requests.');
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Bright Data Web Unlocker zone not configured. Either:
|
|
71
|
+
` + ` 1. Set brightdataUnlockerZone in ${CONFIG_PATH}
|
|
72
|
+
` + ` 2. Set BRIGHTDATA_UNLOCKER_ZONE environment variable
|
|
73
|
+
` + 'The zone must be of type "unblocker"; a SERP zone will not serve Web Unlocker requests.');
|
|
74
|
+
}
|
|
75
|
+
async function getApiKey(signal) {
|
|
76
|
+
const apiKey = await resolveCredential({
|
|
77
|
+
provider: "Bright Data",
|
|
78
|
+
configuredValue: loadConfig().brightdataApiKey,
|
|
79
|
+
environmentValue: process.env.BRIGHTDATA_API_KEY,
|
|
80
|
+
signal
|
|
81
|
+
});
|
|
82
|
+
if (!apiKey) {
|
|
83
|
+
throw new Error(`Bright Data API key not found. Either:
|
|
84
|
+
` + ` 1. Create ${CONFIG_PATH} with { "brightdataApiKey": "your-key" }
|
|
85
|
+
` + ` 2. Set BRIGHTDATA_API_KEY environment variable
|
|
86
|
+
` + "Get a key at https://brightdata.com/cp/setting/users");
|
|
87
|
+
}
|
|
88
|
+
return apiKey;
|
|
89
|
+
}
|
|
90
|
+
function requestSignal(timeoutMs, signal) {
|
|
91
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
92
|
+
return signal ? AbortSignal.any([timeout, signal]) : timeout;
|
|
93
|
+
}
|
|
94
|
+
function errorMessage(err) {
|
|
95
|
+
return err instanceof Error ? err.message : String(err);
|
|
96
|
+
}
|
|
97
|
+
function isAbortError(err) {
|
|
98
|
+
return errorMessage(err).toLowerCase().includes("abort");
|
|
99
|
+
}
|
|
100
|
+
function ssrfOptions(options) {
|
|
101
|
+
return {
|
|
102
|
+
allowRanges: options?.ssrf?.allowRanges ?? [],
|
|
103
|
+
trustEnvProxy: options?.ssrf?.trustEnvProxy ?? false,
|
|
104
|
+
...options?.lookup ? { lookup: options.lookup } : {}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function withoutSensitiveHeaders(headers) {
|
|
108
|
+
const next = { ...headers };
|
|
109
|
+
delete next.Authorization;
|
|
110
|
+
delete next.authorization;
|
|
111
|
+
delete next.Cookie;
|
|
112
|
+
delete next.cookie;
|
|
113
|
+
delete next["X-API-Key"];
|
|
114
|
+
delete next["x-api-key"];
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
async function fetchBrightDataApi(url, init, options) {
|
|
118
|
+
let current = await validateRemoteUrl(url, ssrfOptions(options));
|
|
119
|
+
let headers = init.headers;
|
|
120
|
+
for (let redirects = 0;; redirects++) {
|
|
121
|
+
const response = await fetch(current, { ...init, headers, redirect: "manual" });
|
|
122
|
+
if (!REDIRECT_STATUSES.has(response.status))
|
|
123
|
+
return response;
|
|
124
|
+
const location = response.headers.get("location");
|
|
125
|
+
if (!location)
|
|
126
|
+
return response;
|
|
127
|
+
if (redirects === DEFAULT_MAX_REDIRECTS)
|
|
128
|
+
throw new Error(`Too many redirects fetching ${current.toString()}`);
|
|
129
|
+
const next = await validateRemoteUrl(new URL(location, current), ssrfOptions(options));
|
|
130
|
+
if (next.origin !== current.origin)
|
|
131
|
+
headers = withoutSensitiveHeaders(headers);
|
|
132
|
+
current = next;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function unlockerBody(url, zone) {
|
|
136
|
+
return {
|
|
137
|
+
url,
|
|
138
|
+
zone,
|
|
139
|
+
format: "raw",
|
|
140
|
+
data_format: "markdown"
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function brightDataRequest(url, zone, signal, options) {
|
|
144
|
+
const apiKey = await getApiKey(signal);
|
|
145
|
+
const headers = {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
Authorization: `Bearer ${apiKey}`
|
|
148
|
+
};
|
|
149
|
+
const activityId = activityMonitor.logStart({ type: "fetch", url });
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetchBrightDataApi(BRIGHTDATA_REQUEST_URL, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers,
|
|
154
|
+
body: JSON.stringify(unlockerBody(url, zone)),
|
|
155
|
+
signal: requestSignal(options?.timeoutMs ?? EXTRACT_TIMEOUT_MS, signal)
|
|
156
|
+
}, options);
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
const errorText = await response.text().catch(() => "");
|
|
159
|
+
throw new Error(`Bright Data Web Unlocker error ${response.status}: ${redactCredential(errorText, apiKey).slice(0, 300)}`);
|
|
160
|
+
}
|
|
161
|
+
const text = await response.text();
|
|
162
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
163
|
+
return text;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const message = errorMessage(err);
|
|
166
|
+
const redactedMessage = redactCredential(message, apiKey);
|
|
167
|
+
if (isAbortError(err))
|
|
168
|
+
activityMonitor.logComplete(activityId, 0);
|
|
169
|
+
else
|
|
170
|
+
activityMonitor.logError(activityId, redactedMessage);
|
|
171
|
+
if (redactedMessage === message)
|
|
172
|
+
throw err;
|
|
173
|
+
const redactedError = new Error(redactedMessage);
|
|
174
|
+
if (err instanceof Error)
|
|
175
|
+
redactedError.name = err.name;
|
|
176
|
+
throw redactedError;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function headingTitle(text) {
|
|
180
|
+
const match = text.match(/^#{1,2}\s+(.+)/m);
|
|
181
|
+
if (!match)
|
|
182
|
+
return "";
|
|
183
|
+
return match[1].replace(/\*+/g, "").trim();
|
|
184
|
+
}
|
|
185
|
+
export function isBrightDataUnlockerAvailable() {
|
|
186
|
+
if (getZone() === null)
|
|
187
|
+
return false;
|
|
188
|
+
return hasCredentialSource({
|
|
189
|
+
provider: "Bright Data",
|
|
190
|
+
configuredValue: loadConfig().brightdataApiKey,
|
|
191
|
+
environmentValue: process.env.BRIGHTDATA_API_KEY
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export async function extractWithBrightDataUnlocker(url, signal, options) {
|
|
195
|
+
const zone = requireZone();
|
|
196
|
+
await validateRemoteUrl(url, ssrfOptions(options));
|
|
197
|
+
const raw = await brightDataRequest(url, zone, signal, options);
|
|
198
|
+
const content = raw.trim();
|
|
199
|
+
if (!content)
|
|
200
|
+
return null;
|
|
201
|
+
return { url, title: headingTitle(content), content, error: null };
|
|
202
|
+
}
|