@bacnh85/pi-web 0.5.1 → 0.5.2
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 +1 -1
- 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 +43 -0
- package/extensions/lib/retry.ts +20 -3
- package/extensions/lib/search.ts +2 -15
- 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
|
@@ -172,7 +172,7 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
|
|
|
172
172
|
const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
|
|
173
173
|
const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
|
|
174
174
|
const apiKey = explicitApiKey || apiKeyLookup.value || "";
|
|
175
|
-
const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
|
|
175
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
176
176
|
const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
|
|
177
177
|
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
178
178
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
@@ -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,6 +36,18 @@ 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
53
|
let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
|
|
@@ -48,6 +60,37 @@ export function truncateText(text: string): string {
|
|
|
48
60
|
return output;
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Unified search result formatting
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface UnifiedSearchResult {
|
|
68
|
+
title: string;
|
|
69
|
+
url: string;
|
|
70
|
+
snippet: string;
|
|
71
|
+
age: string;
|
|
72
|
+
content: string;
|
|
73
|
+
backend: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
|
|
77
|
+
if (!results.length) return "No results found.";
|
|
78
|
+
return results
|
|
79
|
+
.map((r, i) =>
|
|
80
|
+
[
|
|
81
|
+
`--- Result ${i + 1} (backend: ${r.backend}) ---`,
|
|
82
|
+
`Title: ${r.title || ""}`,
|
|
83
|
+
`Link: ${r.url || ""}`,
|
|
84
|
+
r.age ? `Age: ${r.age}` : "",
|
|
85
|
+
r.snippet ? `Snippet: ${r.snippet}` : "",
|
|
86
|
+
r.content ? `Content:\n${r.content}` : "",
|
|
87
|
+
]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join("\n"),
|
|
90
|
+
)
|
|
91
|
+
.join("\n\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
51
94
|
// ---------------------------------------------------------------------------
|
|
52
95
|
// Firecrawl scrape formatting
|
|
53
96
|
// ---------------------------------------------------------------------------
|
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";
|
|
@@ -187,15 +187,6 @@ export function selectSearchBackendOrder(params: SearchParams): SearchBackend[]
|
|
|
187
187
|
return ["searxng", "brave", "firecrawl"];
|
|
188
188
|
}
|
|
189
189
|
|
|
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
190
|
function isConfigured(backend: SearchBackend, backends: BackendConfig): boolean {
|
|
200
191
|
if (backend === "searxng") return backends.searxng.configured;
|
|
201
192
|
if (backend === "brave") return backends.brave.configured;
|
|
@@ -231,10 +222,6 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
|
|
|
231
222
|
}
|
|
232
223
|
}
|
|
233
224
|
|
|
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
|
-
);
|
|
225
|
+
return { results: [], attempts, selectedBackend: "" as const, backendOrder };
|
|
239
226
|
}
|
|
240
227
|
|
package/package.json
CHANGED