@brightdata/brightdata-plugin 1.0.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.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/index.ts +30 -0
- package/openclaw.plugin.json +63 -0
- package/package.json +45 -0
- package/src/brightdata-batch-tools.ts +274 -0
- package/src/brightdata-browser-tools.ts +1575 -0
- package/src/brightdata-client.ts +907 -0
- package/src/brightdata-scrape-tool.ts +69 -0
- package/src/brightdata-search-provider.ts +76 -0
- package/src/brightdata-search-tool.ts +88 -0
- package/src/brightdata-web-data-tools.ts +501 -0
- package/src/brightdata-zone-bootstrap.ts +177 -0
- package/src/config.ts +155 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import { markdownToText, truncateText } from "openclaw/plugin-sdk/agent-runtime";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_CACHE_TTL_MINUTES,
|
|
4
|
+
normalizeCacheKey,
|
|
5
|
+
readCache,
|
|
6
|
+
readResponseText,
|
|
7
|
+
resolveCacheTtlMs,
|
|
8
|
+
withTrustedWebToolsEndpoint,
|
|
9
|
+
writeCache,
|
|
10
|
+
} from "openclaw/plugin-sdk/provider-web-search";
|
|
11
|
+
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
12
|
+
import { wrapExternalContent, wrapWebContent } from "openclaw/plugin-sdk/security-runtime";
|
|
13
|
+
import {
|
|
14
|
+
ensureBrightDataZoneExists,
|
|
15
|
+
resetEnsuredBrightDataZones,
|
|
16
|
+
type BrightDataZoneKind,
|
|
17
|
+
} from "./brightdata-zone-bootstrap.js";
|
|
18
|
+
import {
|
|
19
|
+
type BrightDataPluginConfig,
|
|
20
|
+
DEFAULT_BRIGHTDATA_BASE_URL,
|
|
21
|
+
DEFAULT_BRIGHTDATA_UNLOCKER_ZONE,
|
|
22
|
+
resolveBrightDataApiToken,
|
|
23
|
+
resolveBrightDataBaseUrl,
|
|
24
|
+
resolveBrightDataBrowserZone,
|
|
25
|
+
resolveBrightDataPollingTimeoutSeconds,
|
|
26
|
+
resolveBrightDataScrapeTimeoutSeconds,
|
|
27
|
+
resolveBrightDataSearchTimeoutSeconds,
|
|
28
|
+
resolveBrightDataUnlockerZone,
|
|
29
|
+
} from "./config.js";
|
|
30
|
+
|
|
31
|
+
const SEARCH_CACHE = new Map<
|
|
32
|
+
string,
|
|
33
|
+
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
|
34
|
+
>();
|
|
35
|
+
const SCRAPE_CACHE = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
|
38
|
+
>();
|
|
39
|
+
|
|
40
|
+
const DEFAULT_SEARCH_COUNT = 5;
|
|
41
|
+
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
|
|
42
|
+
const DEFAULT_ERROR_MAX_BYTES = 64_000;
|
|
43
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
const PENDING_WEB_DATA_STATUSES = new Set(["running", "building", "starting"]);
|
|
46
|
+
|
|
47
|
+
export type BrightDataSearchEngine = "google" | "bing" | "yandex";
|
|
48
|
+
export type BrightDataScrapeExtractMode = "markdown" | "text" | "html";
|
|
49
|
+
|
|
50
|
+
type BrightDataSearchItem = {
|
|
51
|
+
title: string;
|
|
52
|
+
url: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
siteName?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type CleanGoogleSearchPayload = {
|
|
58
|
+
organic: Array<{
|
|
59
|
+
link: string;
|
|
60
|
+
title: string;
|
|
61
|
+
description: string;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type BrightDataSearchParams = {
|
|
66
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig;
|
|
67
|
+
query: string;
|
|
68
|
+
engine?: BrightDataSearchEngine;
|
|
69
|
+
count?: number;
|
|
70
|
+
cursor?: string;
|
|
71
|
+
geoLocation?: string;
|
|
72
|
+
timeoutSeconds?: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type BrightDataScrapeParams = {
|
|
76
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig;
|
|
77
|
+
url: string;
|
|
78
|
+
extractMode: BrightDataScrapeExtractMode;
|
|
79
|
+
maxChars?: number;
|
|
80
|
+
timeoutSeconds?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type BrightDataWebDataParams = {
|
|
84
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig;
|
|
85
|
+
datasetId: string;
|
|
86
|
+
input: Record<string, unknown>;
|
|
87
|
+
fixedValues?: Record<string, unknown>;
|
|
88
|
+
triggerParams?: Record<string, string | number | boolean>;
|
|
89
|
+
toolName?: string;
|
|
90
|
+
timeoutSeconds?: number;
|
|
91
|
+
pollingTimeoutSeconds?: number;
|
|
92
|
+
onPollAttempt?: (params: {
|
|
93
|
+
attempt: number;
|
|
94
|
+
total: number;
|
|
95
|
+
snapshotId: string;
|
|
96
|
+
}) => Promise<void> | void;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
class BrightDataApiError extends Error {
|
|
100
|
+
status: number;
|
|
101
|
+
detail?: string;
|
|
102
|
+
code?: string;
|
|
103
|
+
|
|
104
|
+
constructor(message: string, params: { status: number; detail?: string; code?: string }) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.name = "BrightDataApiError";
|
|
107
|
+
this.status = params.status;
|
|
108
|
+
this.detail = params.detail;
|
|
109
|
+
this.code = params.code;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveEndpoint(baseUrl: string, pathname: string): string {
|
|
114
|
+
const trimmed = baseUrl.trim();
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(trimmed || DEFAULT_BRIGHTDATA_BASE_URL);
|
|
117
|
+
url.pathname = pathname;
|
|
118
|
+
url.search = "";
|
|
119
|
+
url.hash = "";
|
|
120
|
+
return url.toString();
|
|
121
|
+
} catch {
|
|
122
|
+
return new URL(pathname, DEFAULT_BRIGHTDATA_BASE_URL).toString();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function appendQueryParams(
|
|
127
|
+
urlRaw: string,
|
|
128
|
+
params?: Record<string, string | number | boolean | undefined>,
|
|
129
|
+
): string {
|
|
130
|
+
const url = new URL(urlRaw);
|
|
131
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
132
|
+
if (value === undefined) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
url.searchParams.set(key, String(value));
|
|
136
|
+
}
|
|
137
|
+
return url.toString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveSiteName(urlRaw: string): string | undefined {
|
|
141
|
+
try {
|
|
142
|
+
const host = new URL(urlRaw).hostname.replace(/^www\./, "");
|
|
143
|
+
return host || undefined;
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
|
150
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
151
|
+
return Math.floor(value);
|
|
152
|
+
}
|
|
153
|
+
return fallback;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeSearchCount(value: number | undefined): number {
|
|
157
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
158
|
+
return Math.max(1, Math.min(10, Math.floor(value)));
|
|
159
|
+
}
|
|
160
|
+
return DEFAULT_SEARCH_COUNT;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizePageIndex(cursor: string | undefined): number {
|
|
164
|
+
if (typeof cursor !== "string" || !cursor.trim()) {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
const parsed = Number.parseInt(cursor, 10);
|
|
168
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeGeoLocation(value: string | undefined): string | undefined {
|
|
172
|
+
if (typeof value !== "string") {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const trimmed = value.trim().toLowerCase();
|
|
176
|
+
return trimmed.length === 2 ? trimmed : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildBrightDataSearchUrl(params: {
|
|
180
|
+
query: string;
|
|
181
|
+
engine: BrightDataSearchEngine;
|
|
182
|
+
cursor?: string;
|
|
183
|
+
geoLocation?: string;
|
|
184
|
+
}): string {
|
|
185
|
+
const encodedQuery = encodeURIComponent(params.query);
|
|
186
|
+
const page = normalizePageIndex(params.cursor);
|
|
187
|
+
const start = page * 10;
|
|
188
|
+
if (params.engine === "yandex") {
|
|
189
|
+
return `https://yandex.com/search/?text=${encodedQuery}&p=${page}`;
|
|
190
|
+
}
|
|
191
|
+
if (params.engine === "bing") {
|
|
192
|
+
return `https://www.bing.com/search?q=${encodedQuery}&first=${start + 1}`;
|
|
193
|
+
}
|
|
194
|
+
const geoLocation = normalizeGeoLocation(params.geoLocation);
|
|
195
|
+
const geoParam = geoLocation ? `&gl=${geoLocation}` : "";
|
|
196
|
+
return `https://www.google.com/search?q=${encodedQuery}&start=${start}${geoParam}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function throwBrightDataApiError(params: {
|
|
200
|
+
response: Response;
|
|
201
|
+
errorLabel: string;
|
|
202
|
+
unlockerZone?: string;
|
|
203
|
+
}): Promise<never> {
|
|
204
|
+
const detail = await readResponseText(params.response, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
|
|
205
|
+
const code = params.response.headers.get("x-brd-err-code") ?? undefined;
|
|
206
|
+
if (code === "client_10100" && params.unlockerZone === DEFAULT_BRIGHTDATA_UNLOCKER_ZONE) {
|
|
207
|
+
throw new BrightDataApiError(
|
|
208
|
+
"Bright Data free-tier usage limit reached for the default mcp_unlocker zone. Create a new Web Unlocker zone and configure BRIGHTDATA_UNLOCKER_ZONE or the Bright Data plugin unlocker zone setting before retrying.",
|
|
209
|
+
{
|
|
210
|
+
status: params.response.status,
|
|
211
|
+
detail: detail.text || params.response.statusText,
|
|
212
|
+
code,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
throw new BrightDataApiError(
|
|
217
|
+
`${params.errorLabel} API error (${params.response.status}): ${detail.text || params.response.statusText}`,
|
|
218
|
+
{
|
|
219
|
+
status: params.response.status,
|
|
220
|
+
detail: detail.text || params.response.statusText,
|
|
221
|
+
code,
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildRequestInit(params: {
|
|
227
|
+
method: "GET" | "POST";
|
|
228
|
+
apiToken: string;
|
|
229
|
+
body?: unknown;
|
|
230
|
+
accept?: string;
|
|
231
|
+
}): RequestInit {
|
|
232
|
+
const headers: Record<string, string> = {
|
|
233
|
+
Authorization: `Bearer ${params.apiToken}`,
|
|
234
|
+
};
|
|
235
|
+
if (params.accept) {
|
|
236
|
+
headers.Accept = params.accept;
|
|
237
|
+
}
|
|
238
|
+
if (params.body !== undefined) {
|
|
239
|
+
headers["Content-Type"] = "application/json";
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
method: params.method,
|
|
243
|
+
headers,
|
|
244
|
+
...(params.body !== undefined ? { body: JSON.stringify(params.body) } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function requestBrightDataText(params: {
|
|
249
|
+
baseUrl: string;
|
|
250
|
+
pathname: string;
|
|
251
|
+
apiToken: string;
|
|
252
|
+
timeoutSeconds: number;
|
|
253
|
+
errorLabel: string;
|
|
254
|
+
body?: unknown;
|
|
255
|
+
queryParams?: Record<string, string | number | boolean | undefined>;
|
|
256
|
+
unlockerZone?: string;
|
|
257
|
+
}): Promise<string> {
|
|
258
|
+
const endpoint = appendQueryParams(
|
|
259
|
+
resolveEndpoint(params.baseUrl, params.pathname),
|
|
260
|
+
params.queryParams,
|
|
261
|
+
);
|
|
262
|
+
return await withTrustedWebToolsEndpoint(
|
|
263
|
+
{
|
|
264
|
+
url: endpoint,
|
|
265
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
266
|
+
init: buildRequestInit({
|
|
267
|
+
method: params.body === undefined ? "GET" : "POST",
|
|
268
|
+
apiToken: params.apiToken,
|
|
269
|
+
body: params.body,
|
|
270
|
+
accept: "text/plain, text/html, application/json;q=0.8, */*;q=0.5",
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
async ({ response }) => {
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
return await throwBrightDataApiError({
|
|
276
|
+
response,
|
|
277
|
+
errorLabel: params.errorLabel,
|
|
278
|
+
unlockerZone: params.unlockerZone,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return await response.text();
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function requestBrightDataJson(params: {
|
|
287
|
+
baseUrl: string;
|
|
288
|
+
pathname: string;
|
|
289
|
+
apiToken: string;
|
|
290
|
+
timeoutSeconds: number;
|
|
291
|
+
errorLabel: string;
|
|
292
|
+
body?: unknown;
|
|
293
|
+
queryParams?: Record<string, string | number | boolean | undefined>;
|
|
294
|
+
unlockerZone?: string;
|
|
295
|
+
}): Promise<unknown> {
|
|
296
|
+
const endpoint = appendQueryParams(
|
|
297
|
+
resolveEndpoint(params.baseUrl, params.pathname),
|
|
298
|
+
params.queryParams,
|
|
299
|
+
);
|
|
300
|
+
return await withTrustedWebToolsEndpoint(
|
|
301
|
+
{
|
|
302
|
+
url: endpoint,
|
|
303
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
304
|
+
init: buildRequestInit({
|
|
305
|
+
method: params.body === undefined ? "GET" : "POST",
|
|
306
|
+
apiToken: params.apiToken,
|
|
307
|
+
body: params.body,
|
|
308
|
+
accept: "application/json",
|
|
309
|
+
}),
|
|
310
|
+
},
|
|
311
|
+
async ({ response }) => {
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
return await throwBrightDataApiError({
|
|
314
|
+
response,
|
|
315
|
+
errorLabel: params.errorLabel,
|
|
316
|
+
unlockerZone: params.unlockerZone,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return (await response.json()) as unknown;
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function ensureConfiguredBrightDataZoneExists(params: {
|
|
325
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig;
|
|
326
|
+
kind: BrightDataZoneKind;
|
|
327
|
+
timeoutSeconds?: number;
|
|
328
|
+
}): Promise<boolean> {
|
|
329
|
+
const apiToken = resolveBrightDataApiToken(params.pluginConfig);
|
|
330
|
+
if (!apiToken) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
const baseUrl = resolveBrightDataBaseUrl(params.pluginConfig);
|
|
334
|
+
const zoneName =
|
|
335
|
+
params.kind === "browser"
|
|
336
|
+
? resolveBrightDataBrowserZone(params.pluginConfig)
|
|
337
|
+
: resolveBrightDataUnlockerZone(params.pluginConfig);
|
|
338
|
+
const timeoutSeconds = resolveBrightDataSearchTimeoutSeconds(params.timeoutSeconds);
|
|
339
|
+
return await ensureBrightDataZoneExists({
|
|
340
|
+
requestEndpoint: withTrustedWebToolsEndpoint,
|
|
341
|
+
apiToken,
|
|
342
|
+
baseUrl,
|
|
343
|
+
zoneName,
|
|
344
|
+
kind: params.kind,
|
|
345
|
+
timeoutSeconds,
|
|
346
|
+
onError: (error) => {
|
|
347
|
+
logVerbose(
|
|
348
|
+
`[brightdata] Zone bootstrap failed (${params.kind}/${zoneName}): ${
|
|
349
|
+
error instanceof Error ? error.message : String(error)
|
|
350
|
+
}`,
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function ensureBrightDataUnlockerZoneExists(
|
|
357
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig,
|
|
358
|
+
timeoutSeconds?: number,
|
|
359
|
+
): Promise<boolean> {
|
|
360
|
+
return await ensureConfiguredBrightDataZoneExists({
|
|
361
|
+
pluginConfig,
|
|
362
|
+
kind: "unlocker",
|
|
363
|
+
timeoutSeconds,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function ensureBrightDataBrowserZoneExists(
|
|
368
|
+
pluginConfig?: Record<string, unknown> | BrightDataPluginConfig,
|
|
369
|
+
timeoutSeconds?: number,
|
|
370
|
+
): Promise<boolean> {
|
|
371
|
+
return await ensureConfiguredBrightDataZoneExists({
|
|
372
|
+
pluginConfig,
|
|
373
|
+
kind: "browser",
|
|
374
|
+
timeoutSeconds,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function cleanGoogleSearchPayload(rawData: unknown): CleanGoogleSearchPayload {
|
|
379
|
+
const data =
|
|
380
|
+
rawData && typeof rawData === "object" && !Array.isArray(rawData)
|
|
381
|
+
? (rawData as Record<string, unknown>)
|
|
382
|
+
: {};
|
|
383
|
+
const organicRaw = Array.isArray(data.organic) ? data.organic : [];
|
|
384
|
+
const organic = organicRaw
|
|
385
|
+
.map((entry) => {
|
|
386
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const record = entry as Record<string, unknown>;
|
|
390
|
+
const link = typeof record.link === "string" ? record.link.trim() : "";
|
|
391
|
+
const title = typeof record.title === "string" ? record.title.trim() : "";
|
|
392
|
+
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
393
|
+
if (!link || !title) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
return { link, title, description };
|
|
397
|
+
})
|
|
398
|
+
.filter((entry): entry is { link: string; title: string; description: string } => !!entry);
|
|
399
|
+
return { organic };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveGoogleSearchItems(rawData: unknown): BrightDataSearchItem[] {
|
|
403
|
+
return cleanGoogleSearchPayload(rawData).organic.map((entry) => ({
|
|
404
|
+
title: entry.title,
|
|
405
|
+
url: entry.link,
|
|
406
|
+
description: entry.description || undefined,
|
|
407
|
+
siteName: resolveSiteName(entry.link),
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const RESULT_LINK_LINE_RE =
|
|
412
|
+
/^(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+)?(?:\*\*|__)?\[(.+?)\]\((https?:\/\/[^\s)]+)\)(?:\*\*|__)?(?:\s*(?:[-:|]|[–—])\s*(.+))?$/;
|
|
413
|
+
|
|
414
|
+
function normalizeMarkdownLine(value: string): string {
|
|
415
|
+
return value
|
|
416
|
+
.replace(/^\s*>\s?/, "")
|
|
417
|
+
.replace(/^(?:[-*+]\s+|\d+\.\s+)/, "")
|
|
418
|
+
.trim();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function finalizeMarkdownSearchItem(
|
|
422
|
+
item:
|
|
423
|
+
| {
|
|
424
|
+
title: string;
|
|
425
|
+
url: string;
|
|
426
|
+
descriptionLines: string[];
|
|
427
|
+
}
|
|
428
|
+
| undefined,
|
|
429
|
+
): BrightDataSearchItem | undefined {
|
|
430
|
+
if (!item) {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const description = item.descriptionLines
|
|
434
|
+
.map((line) => normalizeMarkdownLine(line))
|
|
435
|
+
.filter((line) => !!line && line !== item.url && line !== `<${item.url}>`)
|
|
436
|
+
.join(" ")
|
|
437
|
+
.trim();
|
|
438
|
+
return {
|
|
439
|
+
title: item.title,
|
|
440
|
+
url: item.url,
|
|
441
|
+
description: description || undefined,
|
|
442
|
+
siteName: resolveSiteName(item.url),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function dedupeSearchItems(items: BrightDataSearchItem[]): BrightDataSearchItem[] {
|
|
447
|
+
const seen = new Set<string>();
|
|
448
|
+
const deduped: BrightDataSearchItem[] = [];
|
|
449
|
+
for (const item of items) {
|
|
450
|
+
const key = `${item.url}\n${item.title}`;
|
|
451
|
+
if (seen.has(key)) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
seen.add(key);
|
|
455
|
+
deduped.push(item);
|
|
456
|
+
}
|
|
457
|
+
return deduped;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function resolveMarkdownSearchItems(markdown: string): BrightDataSearchItem[] {
|
|
461
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
462
|
+
const items: BrightDataSearchItem[] = [];
|
|
463
|
+
let current:
|
|
464
|
+
| {
|
|
465
|
+
title: string;
|
|
466
|
+
url: string;
|
|
467
|
+
descriptionLines: string[];
|
|
468
|
+
}
|
|
469
|
+
| undefined;
|
|
470
|
+
|
|
471
|
+
// Bright Data returns markdown for Bing/Yandex; treat link lines as result boundaries
|
|
472
|
+
// and fold the following text into a single snippet until the next result starts.
|
|
473
|
+
for (const rawLine of lines) {
|
|
474
|
+
const line = rawLine.trim();
|
|
475
|
+
if (!line) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const match = line.match(RESULT_LINK_LINE_RE);
|
|
479
|
+
if (match) {
|
|
480
|
+
const finalized = finalizeMarkdownSearchItem(current);
|
|
481
|
+
if (finalized) {
|
|
482
|
+
items.push(finalized);
|
|
483
|
+
}
|
|
484
|
+
current = {
|
|
485
|
+
title: match[1].trim(),
|
|
486
|
+
url: match[2].trim(),
|
|
487
|
+
descriptionLines: match[3] ? [match[3].trim()] : [],
|
|
488
|
+
};
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (current) {
|
|
492
|
+
current.descriptionLines.push(rawLine);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const finalized = finalizeMarkdownSearchItem(current);
|
|
497
|
+
if (finalized) {
|
|
498
|
+
items.push(finalized);
|
|
499
|
+
}
|
|
500
|
+
if (items.length > 0) {
|
|
501
|
+
return dedupeSearchItems(items);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const fallbackMatches = Array.from(markdown.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g));
|
|
505
|
+
const fallbackItems: BrightDataSearchItem[] = [];
|
|
506
|
+
for (const match of fallbackMatches) {
|
|
507
|
+
const title = match[1]?.trim() ?? "";
|
|
508
|
+
const url = match[2]?.trim() ?? "";
|
|
509
|
+
if (!title || !url) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const siteName = resolveSiteName(url);
|
|
513
|
+
fallbackItems.push(siteName ? { title, url, siteName } : { title, url });
|
|
514
|
+
}
|
|
515
|
+
return dedupeSearchItems(fallbackItems);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function resolveBrightDataSearchItems(params: {
|
|
519
|
+
engine: BrightDataSearchEngine;
|
|
520
|
+
body: string;
|
|
521
|
+
}): BrightDataSearchItem[] {
|
|
522
|
+
if (params.engine === "google") {
|
|
523
|
+
try {
|
|
524
|
+
return resolveGoogleSearchItems(JSON.parse(params.body) as unknown);
|
|
525
|
+
} catch {
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return resolveMarkdownSearchItems(params.body);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function buildSearchPayload(params: {
|
|
533
|
+
query: string;
|
|
534
|
+
engine: BrightDataSearchEngine;
|
|
535
|
+
cursor?: string;
|
|
536
|
+
geoLocation?: string;
|
|
537
|
+
items: BrightDataSearchItem[];
|
|
538
|
+
tookMs: number;
|
|
539
|
+
}): Record<string, unknown> {
|
|
540
|
+
const page = normalizePageIndex(params.cursor);
|
|
541
|
+
return {
|
|
542
|
+
query: params.query,
|
|
543
|
+
provider: "brightdata",
|
|
544
|
+
engine: params.engine,
|
|
545
|
+
count: params.items.length,
|
|
546
|
+
tookMs: params.tookMs,
|
|
547
|
+
cursor: String(page),
|
|
548
|
+
nextCursor: String(page + 1),
|
|
549
|
+
...(params.geoLocation ? { geoLocation: normalizeGeoLocation(params.geoLocation) } : {}),
|
|
550
|
+
externalContent: {
|
|
551
|
+
untrusted: true,
|
|
552
|
+
source: "web_search",
|
|
553
|
+
provider: "brightdata",
|
|
554
|
+
wrapped: true,
|
|
555
|
+
},
|
|
556
|
+
results: params.items.map((entry) => ({
|
|
557
|
+
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
|
558
|
+
url: entry.url,
|
|
559
|
+
description: entry.description ? wrapWebContent(entry.description, "web_search") : "",
|
|
560
|
+
...(entry.siteName ? { siteName: entry.siteName } : {}),
|
|
561
|
+
})),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function runBrightDataSearch(
|
|
566
|
+
params: BrightDataSearchParams,
|
|
567
|
+
): Promise<Record<string, unknown>> {
|
|
568
|
+
const apiToken = resolveBrightDataApiToken(params.pluginConfig);
|
|
569
|
+
if (!apiToken) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
"web_search (brightdata) needs a Bright Data API token. Set BRIGHTDATA_API_TOKEN in the Gateway environment, or configure plugins.entries.brightdata.config.webSearch.apiKey.",
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const engine = params.engine ?? "google";
|
|
575
|
+
const count = normalizeSearchCount(params.count);
|
|
576
|
+
const timeoutSeconds = resolveBrightDataSearchTimeoutSeconds(params.timeoutSeconds);
|
|
577
|
+
const baseUrl = resolveBrightDataBaseUrl(params.pluginConfig);
|
|
578
|
+
const unlockerZone = resolveBrightDataUnlockerZone(params.pluginConfig);
|
|
579
|
+
const geoLocation = normalizeGeoLocation(params.geoLocation);
|
|
580
|
+
const cacheKey = normalizeCacheKey(
|
|
581
|
+
JSON.stringify({
|
|
582
|
+
type: "brightdata-search",
|
|
583
|
+
query: params.query,
|
|
584
|
+
engine,
|
|
585
|
+
count,
|
|
586
|
+
cursor: params.cursor ?? "",
|
|
587
|
+
geoLocation: geoLocation ?? "",
|
|
588
|
+
baseUrl,
|
|
589
|
+
unlockerZone,
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
const cached = readCache(SEARCH_CACHE, cacheKey);
|
|
593
|
+
if (cached) {
|
|
594
|
+
return { ...cached.value, cached: true };
|
|
595
|
+
}
|
|
596
|
+
await ensureBrightDataUnlockerZoneExists(params.pluginConfig, timeoutSeconds);
|
|
597
|
+
|
|
598
|
+
const requestUrlBase = buildBrightDataSearchUrl({
|
|
599
|
+
query: params.query,
|
|
600
|
+
engine,
|
|
601
|
+
cursor: params.cursor,
|
|
602
|
+
geoLocation,
|
|
603
|
+
});
|
|
604
|
+
const requestUrl =
|
|
605
|
+
engine === "google"
|
|
606
|
+
? `${requestUrlBase}${requestUrlBase.includes("?") ? "&" : "?"}brd_json=1`
|
|
607
|
+
: requestUrlBase;
|
|
608
|
+
const startedAt = Date.now();
|
|
609
|
+
const body = await requestBrightDataText({
|
|
610
|
+
baseUrl,
|
|
611
|
+
pathname: "/request",
|
|
612
|
+
apiToken,
|
|
613
|
+
timeoutSeconds,
|
|
614
|
+
errorLabel: "Bright Data Search",
|
|
615
|
+
unlockerZone,
|
|
616
|
+
body: {
|
|
617
|
+
url: requestUrl,
|
|
618
|
+
zone: unlockerZone,
|
|
619
|
+
format: "raw",
|
|
620
|
+
...(engine === "google" ? { data_format: "parsed_light" } : { data_format: "markdown" }),
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
const result = buildSearchPayload({
|
|
624
|
+
query: params.query,
|
|
625
|
+
engine,
|
|
626
|
+
cursor: params.cursor,
|
|
627
|
+
geoLocation,
|
|
628
|
+
items: resolveBrightDataSearchItems({ engine, body }).slice(0, count),
|
|
629
|
+
tookMs: Date.now() - startedAt,
|
|
630
|
+
});
|
|
631
|
+
writeCache(
|
|
632
|
+
SEARCH_CACHE,
|
|
633
|
+
cacheKey,
|
|
634
|
+
result,
|
|
635
|
+
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
|
|
636
|
+
);
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function normalizeMarkdownContent(value: string): string {
|
|
641
|
+
return value
|
|
642
|
+
.replace(/\r\n?/g, "\n")
|
|
643
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
644
|
+
.trim();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function parseBrightDataScrapeBody(params: {
|
|
648
|
+
body: string;
|
|
649
|
+
url: string;
|
|
650
|
+
extractMode: BrightDataScrapeExtractMode;
|
|
651
|
+
maxChars: number;
|
|
652
|
+
}): Record<string, unknown> {
|
|
653
|
+
const normalizedInput =
|
|
654
|
+
params.extractMode === "html" ? params.body.trim() : normalizeMarkdownContent(params.body);
|
|
655
|
+
if (!normalizedInput) {
|
|
656
|
+
throw new Error("Bright Data scrape returned no content.");
|
|
657
|
+
}
|
|
658
|
+
const rawText =
|
|
659
|
+
params.extractMode === "text" ? markdownToText(normalizedInput).trim() : normalizedInput;
|
|
660
|
+
const truncated = truncateText(rawText, params.maxChars);
|
|
661
|
+
const wrappedText = wrapExternalContent(truncated.text, {
|
|
662
|
+
source: "web_fetch",
|
|
663
|
+
includeWarning: false,
|
|
664
|
+
});
|
|
665
|
+
return {
|
|
666
|
+
url: params.url,
|
|
667
|
+
finalUrl: params.url,
|
|
668
|
+
extractor: "brightdata",
|
|
669
|
+
extractMode: params.extractMode,
|
|
670
|
+
externalContent: {
|
|
671
|
+
untrusted: true,
|
|
672
|
+
source: "web_fetch",
|
|
673
|
+
wrapped: true,
|
|
674
|
+
},
|
|
675
|
+
truncated: truncated.truncated,
|
|
676
|
+
rawLength: rawText.length,
|
|
677
|
+
wrappedLength: wrappedText.length,
|
|
678
|
+
text: wrappedText,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function runBrightDataScrape(
|
|
683
|
+
params: BrightDataScrapeParams,
|
|
684
|
+
): Promise<Record<string, unknown>> {
|
|
685
|
+
const apiToken = resolveBrightDataApiToken(params.pluginConfig);
|
|
686
|
+
if (!apiToken) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
"brightdata_scrape needs a Bright Data API token. Set BRIGHTDATA_API_TOKEN in the Gateway environment, or configure plugins.entries.brightdata.config.webSearch.apiKey.",
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
const baseUrl = resolveBrightDataBaseUrl(params.pluginConfig);
|
|
692
|
+
const unlockerZone = resolveBrightDataUnlockerZone(params.pluginConfig);
|
|
693
|
+
const timeoutSeconds = resolveBrightDataScrapeTimeoutSeconds(
|
|
694
|
+
params.pluginConfig,
|
|
695
|
+
params.timeoutSeconds,
|
|
696
|
+
);
|
|
697
|
+
const maxChars = normalizePositiveInteger(params.maxChars, DEFAULT_SCRAPE_MAX_CHARS);
|
|
698
|
+
const cacheKey = normalizeCacheKey(
|
|
699
|
+
JSON.stringify({
|
|
700
|
+
type: "brightdata-scrape",
|
|
701
|
+
url: params.url,
|
|
702
|
+
extractMode: params.extractMode,
|
|
703
|
+
baseUrl,
|
|
704
|
+
unlockerZone,
|
|
705
|
+
maxChars,
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
const cached = readCache(SCRAPE_CACHE, cacheKey);
|
|
709
|
+
if (cached) {
|
|
710
|
+
return { ...cached.value, cached: true };
|
|
711
|
+
}
|
|
712
|
+
await ensureBrightDataUnlockerZoneExists(params.pluginConfig, timeoutSeconds);
|
|
713
|
+
|
|
714
|
+
const body = await requestBrightDataText({
|
|
715
|
+
baseUrl,
|
|
716
|
+
pathname: "/request",
|
|
717
|
+
apiToken,
|
|
718
|
+
timeoutSeconds,
|
|
719
|
+
errorLabel: "Bright Data Scrape",
|
|
720
|
+
unlockerZone,
|
|
721
|
+
body: {
|
|
722
|
+
url: params.url,
|
|
723
|
+
zone: unlockerZone,
|
|
724
|
+
format: "raw",
|
|
725
|
+
...(params.extractMode === "html" ? {} : { data_format: "markdown" }),
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
const result = parseBrightDataScrapeBody({
|
|
729
|
+
body,
|
|
730
|
+
url: params.url,
|
|
731
|
+
extractMode: params.extractMode,
|
|
732
|
+
maxChars,
|
|
733
|
+
});
|
|
734
|
+
writeCache(
|
|
735
|
+
SCRAPE_CACHE,
|
|
736
|
+
cacheKey,
|
|
737
|
+
result,
|
|
738
|
+
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
|
|
739
|
+
);
|
|
740
|
+
return result;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function stripNullish(value: unknown): unknown {
|
|
744
|
+
if (value == null) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
if (Array.isArray(value)) {
|
|
748
|
+
return value.map((entry) => stripNullish(entry)).filter((entry) => entry !== undefined);
|
|
749
|
+
}
|
|
750
|
+
if (typeof value === "object") {
|
|
751
|
+
const result: Record<string, unknown> = {};
|
|
752
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
753
|
+
const normalized = stripNullish(entry);
|
|
754
|
+
if (normalized !== undefined) {
|
|
755
|
+
result[key] = normalized;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
return value;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function readSnapshotStatus(value: unknown): string | undefined {
|
|
764
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
const status = (value as Record<string, unknown>).status;
|
|
768
|
+
return typeof status === "string" && status.trim() ? status.trim().toLowerCase() : undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function normalizeBrightDataWebDataPayload(params: {
|
|
772
|
+
datasetId: string;
|
|
773
|
+
snapshotId: string;
|
|
774
|
+
payload: unknown;
|
|
775
|
+
}): Record<string, unknown> {
|
|
776
|
+
const cleaned = stripNullish(params.payload);
|
|
777
|
+
if (cleaned && typeof cleaned === "object" && !Array.isArray(cleaned)) {
|
|
778
|
+
const record = { ...(cleaned as Record<string, unknown>) };
|
|
779
|
+
if (record.snapshotId === undefined && record.snapshot_id === undefined) {
|
|
780
|
+
record.snapshotId = params.snapshotId;
|
|
781
|
+
}
|
|
782
|
+
if (record.datasetId === undefined && record.dataset_id === undefined) {
|
|
783
|
+
record.datasetId = params.datasetId;
|
|
784
|
+
}
|
|
785
|
+
return record;
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
datasetId: params.datasetId,
|
|
789
|
+
snapshotId: params.snapshotId,
|
|
790
|
+
data: cleaned,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function isRetryablePollingError(error: unknown): boolean {
|
|
795
|
+
return !(error instanceof BrightDataApiError && error.status === 400);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function sleep(ms: number): Promise<void> {
|
|
799
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export async function runBrightDataWebData(
|
|
803
|
+
params: BrightDataWebDataParams,
|
|
804
|
+
): Promise<Record<string, unknown>> {
|
|
805
|
+
const apiToken = resolveBrightDataApiToken(params.pluginConfig);
|
|
806
|
+
if (!apiToken) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
"Bright Data web_data tools need a Bright Data API token. Set BRIGHTDATA_API_TOKEN in the Gateway environment, or configure plugins.entries.brightdata.config.webSearch.apiKey.",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const baseUrl = resolveBrightDataBaseUrl(params.pluginConfig);
|
|
812
|
+
const timeoutSeconds = resolveBrightDataSearchTimeoutSeconds(params.timeoutSeconds);
|
|
813
|
+
const pollingTimeoutSeconds = normalizePositiveInteger(
|
|
814
|
+
params.pollingTimeoutSeconds,
|
|
815
|
+
resolveBrightDataPollingTimeoutSeconds(params.pluginConfig),
|
|
816
|
+
);
|
|
817
|
+
const toolName = params.toolName?.trim() || `brightdata_${params.datasetId}`;
|
|
818
|
+
const input = { ...params.input, ...(params.fixedValues ?? {}) };
|
|
819
|
+
const triggerPayload = await requestBrightDataJson({
|
|
820
|
+
baseUrl,
|
|
821
|
+
pathname: "/datasets/v3/trigger",
|
|
822
|
+
apiToken,
|
|
823
|
+
timeoutSeconds,
|
|
824
|
+
errorLabel: `${toolName} trigger`,
|
|
825
|
+
queryParams: {
|
|
826
|
+
dataset_id: params.datasetId,
|
|
827
|
+
include_errors: true,
|
|
828
|
+
...(params.triggerParams ?? {}),
|
|
829
|
+
},
|
|
830
|
+
body: [input],
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const snapshotId =
|
|
834
|
+
triggerPayload &&
|
|
835
|
+
typeof triggerPayload === "object" &&
|
|
836
|
+
!Array.isArray(triggerPayload) &&
|
|
837
|
+
typeof (triggerPayload as Record<string, unknown>).snapshot_id === "string"
|
|
838
|
+
? ((triggerPayload as Record<string, unknown>).snapshot_id as string)
|
|
839
|
+
: "";
|
|
840
|
+
if (!snapshotId) {
|
|
841
|
+
throw new Error("Bright Data dataset trigger returned no snapshot ID.");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let attempts = 0;
|
|
845
|
+
let lastError: unknown;
|
|
846
|
+
while (attempts < pollingTimeoutSeconds) {
|
|
847
|
+
if (params.onPollAttempt) {
|
|
848
|
+
await params.onPollAttempt({
|
|
849
|
+
attempt: attempts + 1,
|
|
850
|
+
total: pollingTimeoutSeconds,
|
|
851
|
+
snapshotId,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
const snapshotPayload = await requestBrightDataJson({
|
|
856
|
+
baseUrl,
|
|
857
|
+
pathname: `/datasets/v3/snapshot/${encodeURIComponent(snapshotId)}`,
|
|
858
|
+
apiToken,
|
|
859
|
+
timeoutSeconds,
|
|
860
|
+
errorLabel: `${toolName} snapshot`,
|
|
861
|
+
queryParams: { format: "json" },
|
|
862
|
+
});
|
|
863
|
+
const status = readSnapshotStatus(snapshotPayload);
|
|
864
|
+
if (status && PENDING_WEB_DATA_STATUSES.has(status)) {
|
|
865
|
+
attempts++;
|
|
866
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
return normalizeBrightDataWebDataPayload({
|
|
870
|
+
datasetId: params.datasetId,
|
|
871
|
+
snapshotId,
|
|
872
|
+
payload: snapshotPayload,
|
|
873
|
+
});
|
|
874
|
+
} catch (error) {
|
|
875
|
+
lastError = error;
|
|
876
|
+
if (!isRetryablePollingError(error)) {
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
attempts++;
|
|
880
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (lastError instanceof Error && lastError.message) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`Timeout after ${pollingTimeoutSeconds} seconds waiting for Bright Data dataset results: ${lastError.message}`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Timeout after ${pollingTimeoutSeconds} seconds waiting for Bright Data dataset results.`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export const __testing = {
|
|
895
|
+
BrightDataApiError,
|
|
896
|
+
buildBrightDataSearchUrl,
|
|
897
|
+
cleanGoogleSearchPayload,
|
|
898
|
+
ensureBrightDataBrowserZoneExists,
|
|
899
|
+
ensureBrightDataUnlockerZoneExists,
|
|
900
|
+
normalizeBrightDataWebDataPayload,
|
|
901
|
+
parseBrightDataScrapeBody,
|
|
902
|
+
resetEnsuredBrightDataZones: () => {
|
|
903
|
+
resetEnsuredBrightDataZones();
|
|
904
|
+
},
|
|
905
|
+
resolveBrightDataSearchItems,
|
|
906
|
+
resolveMarkdownSearchItems,
|
|
907
|
+
};
|