@bacnh85/pi-web 0.5.1 → 0.5.3
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/extensions/index.ts +2 -23
- package/extensions/lib/brave.ts +2 -1
- package/extensions/lib/config.ts +12 -6
- package/extensions/lib/content.ts +18 -10
- package/extensions/lib/crawl4ai.ts +3 -23
- package/extensions/lib/extract.ts +10 -15
- package/extensions/lib/firecrawl.ts +3 -15
- package/extensions/lib/format.ts +48 -8
- package/extensions/lib/retry.ts +20 -3
- package/extensions/lib/search.ts +18 -25
- package/extensions/lib/searxng.ts +1 -1
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
truncateText,
|
|
19
19
|
formatFirecrawlScrape,
|
|
20
20
|
formatCrawl4aiResult,
|
|
21
|
+
formatUnifiedSearchResults,
|
|
21
22
|
} from "./lib/format";
|
|
22
|
-
import { searchWithDiagnostics
|
|
23
|
+
import { searchWithDiagnostics } from "./lib/search";
|
|
23
24
|
import { extractWithDiagnostics, type ExtractMode } from "./lib/extract";
|
|
24
25
|
import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
|
|
25
26
|
import {
|
|
@@ -48,28 +49,6 @@ const crawl4aiControlSchema = {
|
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Helper: format unified search results for text output
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
|
|
56
|
-
if (!results.length) return "No results found.";
|
|
57
|
-
return results
|
|
58
|
-
.map((r, i) =>
|
|
59
|
-
[
|
|
60
|
-
`--- Result ${i + 1} (backend: ${r.backend}) ---`,
|
|
61
|
-
`Title: ${r.title || ""}`,
|
|
62
|
-
`Link: ${r.url || ""}`,
|
|
63
|
-
r.age ? `Age: ${r.age}` : "",
|
|
64
|
-
r.snippet ? `Snippet: ${r.snippet}` : "",
|
|
65
|
-
r.content ? `Content:\n${r.content}` : "",
|
|
66
|
-
]
|
|
67
|
-
.filter(Boolean)
|
|
68
|
-
.join("\n"),
|
|
69
|
-
)
|
|
70
|
-
.join("\n\n");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
52
|
// ---------------------------------------------------------------------------
|
|
74
53
|
// Extension entry point
|
|
75
54
|
// ---------------------------------------------------------------------------
|
package/extensions/lib/brave.ts
CHANGED
|
@@ -16,6 +16,7 @@ export async function fetchBraveResults(
|
|
|
16
16
|
signal?: AbortSignal,
|
|
17
17
|
): Promise<BraveResult[]> {
|
|
18
18
|
return withRetry(async () => {
|
|
19
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Operation aborted.");
|
|
19
20
|
const params = new URLSearchParams({ q: query, count: String(count), country });
|
|
20
21
|
if (freshness) params.append("freshness", freshness);
|
|
21
22
|
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
@@ -36,5 +37,5 @@ export async function fetchBraveResults(
|
|
|
36
37
|
snippet: sanitizeSnippet(r.description || ""),
|
|
37
38
|
age: sanitizeSnippet(r.age || r.page_age || ""),
|
|
38
39
|
}));
|
|
39
|
-
});
|
|
40
|
+
}, undefined, signal);
|
|
40
41
|
}
|
package/extensions/lib/config.ts
CHANGED
|
@@ -9,8 +9,8 @@ import path from "node:path";
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
12
|
-
export const DEFAULT_SEARXNG_BASE_URL = "http://
|
|
13
|
-
export const DEFAULT_CRAWL4AI_API_URL = "http://
|
|
12
|
+
export const DEFAULT_SEARXNG_BASE_URL = "http://127.0.0.1:8888";
|
|
13
|
+
export const DEFAULT_CRAWL4AI_API_URL = "http://127.0.0.1:11235";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Environment loading
|
|
@@ -54,7 +54,7 @@ export function parseDotenvValue(rawValue: string): string {
|
|
|
54
54
|
|
|
55
55
|
export function parseDotenvFile(file: string): Record<string, string> | null {
|
|
56
56
|
let text: string;
|
|
57
|
-
try { text = fs.readFileSync(file, "utf8"); } catch
|
|
57
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
|
|
58
58
|
const values: Record<string, string> = {};
|
|
59
59
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
60
60
|
const line = rawLine.trim();
|
|
@@ -142,7 +142,10 @@ export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = p
|
|
|
142
142
|
const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
|
|
143
143
|
const explicitApiToken = params.crawl4ai_api_token as string | undefined;
|
|
144
144
|
const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
|
|
145
|
-
|
|
145
|
+
// Security: only use env-var API token when URL is default or also from env var.
|
|
146
|
+
// If the URL is overridden via params but no explicit token was provided, don't send
|
|
147
|
+
// the env-var token to a potentially attacker-controlled URL.
|
|
148
|
+
const apiToken = explicitApiToken || (explicitApiUrl ? "" : apiTokenLookup.value) || "";
|
|
146
149
|
const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
147
150
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
148
151
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
@@ -171,8 +174,11 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
|
|
|
171
174
|
const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
|
|
172
175
|
const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
|
|
173
176
|
const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
|
|
174
|
-
|
|
175
|
-
|
|
177
|
+
// Security: only use env-var API key when URL is default or also from env var.
|
|
178
|
+
// If the URL is overridden via params but no explicit key was provided, don't send
|
|
179
|
+
// the env-var key to a potentially attacker-controlled URL.
|
|
180
|
+
const apiKey = explicitApiKey || (apiUrl !== apiUrlLookup.value && apiUrl ? "" : apiKeyLookup.value) || "";
|
|
181
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
176
182
|
const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
|
|
177
183
|
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
178
184
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
@@ -62,17 +62,25 @@ export async function fetchReadableContent(
|
|
|
62
62
|
const html = await response.text();
|
|
63
63
|
const deps = loadReadableContentDependencies();
|
|
64
64
|
const dom = new deps.JSDOM(html, { url });
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
try {
|
|
66
|
+
const article = new deps.Readability(dom.window.document).parse();
|
|
67
|
+
if (article?.content) {
|
|
68
|
+
return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
dom.window.close();
|
|
68
72
|
}
|
|
69
73
|
// Fallback: strip non-content elements and use main/article/body
|
|
70
74
|
const fallbackDoc = new deps.JSDOM(html, { url });
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
try {
|
|
76
|
+
const doc = fallbackDoc.window.document;
|
|
77
|
+
doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: any) => el.remove());
|
|
78
|
+
const title = doc.querySelector("title")?.textContent?.trim() || "";
|
|
79
|
+
const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
|
|
80
|
+
const fallbackHtml = main?.innerHTML || "";
|
|
81
|
+
if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
|
|
82
|
+
return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
|
|
83
|
+
} finally {
|
|
84
|
+
fallbackDoc.window.close();
|
|
85
|
+
}
|
|
78
86
|
}
|
|
@@ -3,19 +3,7 @@
|
|
|
3
3
|
// Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
|
|
4
4
|
|
|
5
5
|
import type { Crawl4aiConfig } from "./config";
|
|
6
|
-
import { signalWithTimeout } from "./retry";
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Error
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
export class Crawl4aiHttpError extends Error {
|
|
13
|
-
status: number;
|
|
14
|
-
constructor(status: number, statusText: string, text: string) {
|
|
15
|
-
super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
|
|
16
|
-
this.status = status;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
6
|
+
import { signalWithTimeout, withRetry, HttpError } from "./retry";
|
|
19
7
|
|
|
20
8
|
// ---------------------------------------------------------------------------
|
|
21
9
|
// Low-level HTTP helpers
|
|
@@ -38,7 +26,7 @@ async function crawl4aiRequestJson(
|
|
|
38
26
|
signal: signalWithTimeout(config.timeoutMs, signal),
|
|
39
27
|
});
|
|
40
28
|
const text = await response.text();
|
|
41
|
-
if (!response.ok) throw new
|
|
29
|
+
if (!response.ok) throw new HttpError(response.status, response.statusText, text);
|
|
42
30
|
return text ? JSON.parse(text) : { success: true };
|
|
43
31
|
}
|
|
44
32
|
|
|
@@ -52,15 +40,7 @@ export async function crawl4aiRequest(
|
|
|
52
40
|
body?: unknown,
|
|
53
41
|
signal?: AbortSignal,
|
|
54
42
|
): Promise<Record<string, unknown>> {
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
return await crawl4aiRequestJson(config, method, endpoint, body, signal);
|
|
58
|
-
} catch (e) {
|
|
59
|
-
if (attempt === 2) throw e;
|
|
60
|
-
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
throw new Error("unreachable");
|
|
43
|
+
return withRetry(() => crawl4aiRequestJson(config, method, endpoint, body, signal), 3, signal);
|
|
64
44
|
}
|
|
65
45
|
|
|
66
46
|
// ---------------------------------------------------------------------------
|
|
@@ -10,7 +10,7 @@ import { loadFirecrawlConfig, loadCrawl4aiConfig, type FirecrawlConfig, type Cra
|
|
|
10
10
|
import { fetchReadableContent } from "./content";
|
|
11
11
|
import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
|
|
12
12
|
import { fetchCrawl4aiMarkdown } from "./crawl4ai";
|
|
13
|
-
import { formatFirecrawlScrape } from "./format";
|
|
13
|
+
import { formatFirecrawlScrape, sanitizeError } from "./format";
|
|
14
14
|
|
|
15
15
|
export type ExtractMode = "auto" | "static" | "dynamic" | "full";
|
|
16
16
|
export type ExtractAttemptStatus = "success" | "empty" | "error";
|
|
@@ -56,14 +56,6 @@ export interface ExtractDiagnostics {
|
|
|
56
56
|
|
|
57
57
|
const MIN_USEFUL_MARKDOWN_CHARS = 120;
|
|
58
58
|
|
|
59
|
-
function sanitizeError(error: unknown): string {
|
|
60
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
61
|
-
return raw
|
|
62
|
-
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
63
|
-
.replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
|
|
64
|
-
.slice(0, 500);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
59
|
function isUseful(result: ExtractResult | null, mode: ExtractMode, explicit: boolean): result is ExtractResult {
|
|
68
60
|
if (!result) return false;
|
|
69
61
|
const length = result.markdown.trim().length;
|
|
@@ -99,12 +91,9 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
|
|
|
99
91
|
throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
|
|
100
92
|
}
|
|
101
93
|
|
|
102
|
-
const formats:
|
|
94
|
+
const formats: string[] = ["markdown"];
|
|
103
95
|
if (params.prompt || params.schema) {
|
|
104
|
-
|
|
105
|
-
if (params.prompt) jsonEntry.prompt = params.prompt;
|
|
106
|
-
if (params.schema) jsonEntry.schema = params.schema;
|
|
107
|
-
formats.push(jsonEntry);
|
|
96
|
+
formats.push("json");
|
|
108
97
|
}
|
|
109
98
|
const body: Record<string, unknown> = {
|
|
110
99
|
url: params.url,
|
|
@@ -113,6 +102,12 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
|
|
|
113
102
|
...(params.wait_for ? { waitFor: params.wait_for } : {}),
|
|
114
103
|
...(params.mobile ? { mobile: true } : {}),
|
|
115
104
|
};
|
|
105
|
+
if (params.prompt || params.schema) {
|
|
106
|
+
const jsonOptions: Record<string, unknown> = {};
|
|
107
|
+
if (params.prompt) jsonOptions.prompt = params.prompt;
|
|
108
|
+
if (params.schema) jsonOptions.schema = params.schema;
|
|
109
|
+
body.jsonOptions = jsonOptions;
|
|
110
|
+
}
|
|
116
111
|
const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
|
|
117
112
|
const structured = findStructuredPayload(result);
|
|
118
113
|
const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
|
|
@@ -129,7 +124,7 @@ async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>)
|
|
|
129
124
|
let c4aiConfig: Crawl4aiConfig;
|
|
130
125
|
try {
|
|
131
126
|
c4aiConfig = loadCrawl4aiConfig(
|
|
132
|
-
{ crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url },
|
|
127
|
+
{ crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url, timeout_ms: params.timeout_ms },
|
|
133
128
|
cwdFromContext(ctx ?? {}),
|
|
134
129
|
includeProjectEnv(ctx ?? {}),
|
|
135
130
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Firecrawl API client (search, scrape, map, crawl).
|
|
2
2
|
|
|
3
3
|
import type { FirecrawlConfig } from "./config";
|
|
4
|
-
import { signalWithTimeout, withRetry } from "./retry";
|
|
4
|
+
import { signalWithTimeout, withRetry, HttpError } from "./retry";
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Types
|
|
@@ -15,18 +15,6 @@ export interface FirecrawlResult {
|
|
|
15
15
|
[key: string]: unknown;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Error
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
export class FirecrawlHttpError extends Error {
|
|
23
|
-
status: number;
|
|
24
|
-
constructor(status: number, statusText: string, text: string) {
|
|
25
|
-
super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
|
|
26
|
-
this.status = status;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
18
|
// ---------------------------------------------------------------------------
|
|
31
19
|
// Low-level HTTP helpers
|
|
32
20
|
// ---------------------------------------------------------------------------
|
|
@@ -48,7 +36,7 @@ async function firecrawlRequestJson(
|
|
|
48
36
|
signal: signalWithTimeout(config.timeoutMs, signal),
|
|
49
37
|
});
|
|
50
38
|
const text = await response.text();
|
|
51
|
-
if (!response.ok) throw new
|
|
39
|
+
if (!response.ok) throw new HttpError(response.status, response.statusText, text);
|
|
52
40
|
return text ? JSON.parse(text) : { success: true };
|
|
53
41
|
}
|
|
54
42
|
|
|
@@ -62,5 +50,5 @@ export async function firecrawlRequest(
|
|
|
62
50
|
body?: unknown,
|
|
63
51
|
signal?: AbortSignal,
|
|
64
52
|
): Promise<Record<string, unknown>> {
|
|
65
|
-
return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal));
|
|
53
|
+
return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal), undefined, signal);
|
|
66
54
|
}
|
package/extensions/lib/format.ts
CHANGED
|
@@ -36,16 +36,56 @@ export function sanitizeSnippet(text = ""): string {
|
|
|
36
36
|
.trim();
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Sanitize an error message — redact sensitive tokens and truncate length.
|
|
41
|
+
*/
|
|
42
|
+
export function sanitizeError(error: unknown): string {
|
|
43
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
44
|
+
return raw
|
|
45
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
46
|
+
.replace(/X-Subscription-Token\s*[:=]\s*[^\s]+/gi, "X-Subscription-Token: [REDACTED]")
|
|
47
|
+
.replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
|
|
48
|
+
.slice(0, 500);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export function truncateText(text: string): string {
|
|
40
52
|
const lines = text.split("\n");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
const lineTruncated = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
|
|
54
|
+
const buf = Buffer.from(lineTruncated, "utf8");
|
|
55
|
+
if (buf.length <= OUTPUT_MAX_BYTES && lines.length <= OUTPUT_MAX_LINES) return text;
|
|
56
|
+
const truncated = buf.slice(0, OUTPUT_MAX_BYTES).toString("utf8").replace(/\uFFFD+$/g, "");
|
|
57
|
+
return truncated + `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Unified search result formatting
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface UnifiedSearchResult {
|
|
65
|
+
title: string;
|
|
66
|
+
url: string;
|
|
67
|
+
snippet: string;
|
|
68
|
+
age: string;
|
|
69
|
+
content: string;
|
|
70
|
+
backend: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
|
|
74
|
+
if (!results.length) return "No results found.";
|
|
75
|
+
return results
|
|
76
|
+
.map((r, i) =>
|
|
77
|
+
[
|
|
78
|
+
`--- Result ${i + 1} (backend: ${r.backend}) ---`,
|
|
79
|
+
`Title: ${r.title || ""}`,
|
|
80
|
+
`Link: ${r.url || ""}`,
|
|
81
|
+
r.age ? `Age: ${r.age}` : "",
|
|
82
|
+
r.snippet ? `Snippet: ${r.snippet}` : "",
|
|
83
|
+
r.content ? `Content:\n${r.content}` : "",
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join("\n"),
|
|
87
|
+
)
|
|
88
|
+
.join("\n\n");
|
|
49
89
|
}
|
|
50
90
|
|
|
51
91
|
// ---------------------------------------------------------------------------
|
package/extensions/lib/retry.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
// Retry utility: signal/timeout helpers.
|
|
1
|
+
// Retry utility: signal/timeout helpers and shared HTTP error class.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Shared HTTP error
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export class HttpError extends Error {
|
|
8
|
+
status: number;
|
|
9
|
+
constructor(status: number, statusText: string, text: string) {
|
|
10
|
+
super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Retry
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, signal?: AbortSignal): Promise<T> {
|
|
4
20
|
let last: unknown;
|
|
5
21
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
22
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Operation aborted.");
|
|
6
23
|
try { return await fn(); }
|
|
7
24
|
catch (error) {
|
|
8
25
|
last = error;
|
|
9
26
|
if (attempt === attempts - 1) break;
|
|
10
|
-
await abortableSleep(1000 * 2 ** attempt);
|
|
27
|
+
await abortableSleep(1000 * 2 ** attempt, signal);
|
|
11
28
|
}
|
|
12
29
|
}
|
|
13
30
|
throw last ?? new Error("retry failed");
|
package/extensions/lib/search.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { fetchSearxngResults } from "./searxng";
|
|
|
10
10
|
import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
|
|
11
11
|
import { loadFirecrawlConfig, loadSearxngConfig, type FirecrawlConfig } from "./config";
|
|
12
12
|
import { fetchReadableContent } from "./content";
|
|
13
|
-
import { sanitizeSnippet } from "./format";
|
|
13
|
+
import { sanitizeSnippet, sanitizeError } from "./format";
|
|
14
14
|
|
|
15
15
|
export type SearchBackend = "searxng" | "brave" | "firecrawl";
|
|
16
16
|
export type SearchAttemptStatus = "skipped" | "success" | "empty" | "error";
|
|
@@ -128,16 +128,22 @@ async function searchBrave(params: SearchParams, backends: BackendConfig): Promi
|
|
|
128
128
|
}));
|
|
129
129
|
if (params.include_content && results.length > 0) {
|
|
130
130
|
const maxChars = params.content_chars ?? 5000;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
// Cap concurrent content fetches to avoid OOM from parallel JSDOM parsing
|
|
132
|
+
const MAX_CONCURRENT_FETCHES = 5;
|
|
133
|
+
const batchSize = Math.min(results.length, MAX_CONCURRENT_FETCHES);
|
|
134
|
+
for (let i = 0; i < results.length; i += batchSize) {
|
|
135
|
+
const batch = results.slice(i, i + batchSize);
|
|
136
|
+
await Promise.all(
|
|
137
|
+
batch.map(async (r) => {
|
|
138
|
+
try {
|
|
139
|
+
const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
|
|
140
|
+
r.content = article.markdown.slice(0, maxChars);
|
|
141
|
+
} catch (e: any) {
|
|
142
|
+
r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
141
147
|
}
|
|
142
148
|
return results;
|
|
143
149
|
}
|
|
@@ -187,15 +193,6 @@ export function selectSearchBackendOrder(params: SearchParams): SearchBackend[]
|
|
|
187
193
|
return ["searxng", "brave", "firecrawl"];
|
|
188
194
|
}
|
|
189
195
|
|
|
190
|
-
function sanitizeError(error: unknown): string {
|
|
191
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
192
|
-
return raw
|
|
193
|
-
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
194
|
-
.replace(/X-Subscription-Token\s*[:=]\s*[^\s]+/gi, "X-Subscription-Token: [REDACTED]")
|
|
195
|
-
.replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
|
|
196
|
-
.slice(0, 500);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
196
|
function isConfigured(backend: SearchBackend, backends: BackendConfig): boolean {
|
|
200
197
|
if (backend === "searxng") return backends.searxng.configured;
|
|
201
198
|
if (backend === "brave") return backends.brave.configured;
|
|
@@ -231,10 +228,6 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
|
|
|
231
228
|
}
|
|
232
229
|
}
|
|
233
230
|
|
|
234
|
-
|
|
235
|
-
throw new Error(
|
|
236
|
-
`All web search backends failed or returned no results.\n${diagnostics}\n` +
|
|
237
|
-
"Use backend to force a provider or check configuration with web_status.",
|
|
238
|
-
);
|
|
231
|
+
return { results: [], attempts, selectedBackend: "" as const, backendOrder };
|
|
239
232
|
}
|
|
240
233
|
|
package/package.json
CHANGED