@bacnh85/pi-web 0.4.1 → 0.4.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/extensions/.mocharc.yml +5 -0
- package/{index.ts → extensions/index.ts} +17 -37
- package/extensions/lib/brave.ts +45 -0
- package/{lib → extensions/lib}/config.ts +1 -4
- package/{lib → extensions/lib}/crawl4ai.ts +11 -45
- package/{lib → extensions/lib}/extract.ts +1 -5
- package/{lib → extensions/lib}/firecrawl.ts +7 -19
- package/{lib → extensions/lib}/format.ts +2 -71
- package/extensions/lib/retry.ts +29 -0
- package/{lib → extensions/lib}/search.ts +2 -6
- package/extensions/lib/searxng.ts +69 -0
- package/extensions/package.json +3 -0
- package/extensions/test/unit/config.test.ts +309 -0
- package/extensions/test/unit/crawl4ai.test.ts +69 -0
- package/extensions/test/unit/extract.test.ts +134 -0
- package/extensions/test/unit/format.test.ts +194 -0
- package/extensions/test/unit/search.test.ts +147 -0
- package/package.json +11 -10
- package/skills/pi-web/SKILL.md +108 -0
- package/lib/brave.ts +0 -40
- package/lib/retry.ts +0 -142
- package/lib/searxng.ts +0 -63
- /package/{lib → extensions/lib}/content.ts +0 -0
- /package/{types.d.ts → extensions/types.d.ts} +0 -0
|
@@ -49,6 +49,7 @@ const crawl4aiControlSchema = {
|
|
|
49
49
|
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Helper: format unified search results for text output
|
|
54
55
|
// ---------------------------------------------------------------------------
|
|
@@ -81,7 +82,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
81
82
|
name: "web_search",
|
|
82
83
|
label: "Web Search",
|
|
83
84
|
description:
|
|
84
|
-
"Search the web
|
|
85
|
+
"Search the web. Auto-selects backends adaptively: SearXNG, Brave, Firecrawl.",
|
|
85
86
|
promptSnippet: "Search current web results",
|
|
86
87
|
promptGuidelines: [
|
|
87
88
|
"Use web_search for source discovery, documentation lookup, current facts, and general web search.",
|
|
@@ -132,7 +133,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
132
133
|
name: "web_extract",
|
|
133
134
|
label: "Web Content Extraction",
|
|
134
135
|
description:
|
|
135
|
-
"Extract readable content from a URL
|
|
136
|
+
"Extract readable content from a URL. Auto backend: static \u2192 dynamic \u2192 full.",
|
|
136
137
|
promptSnippet: "Extract readable webpage content as markdown",
|
|
137
138
|
promptGuidelines: [
|
|
138
139
|
"Use web_extract to get clean markdown content from a known URL.",
|
|
@@ -183,7 +184,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
183
184
|
name: "web_map",
|
|
184
185
|
label: "Site URL Discovery",
|
|
185
186
|
description:
|
|
186
|
-
"Discover URLs from a site using Firecrawl Map.
|
|
187
|
+
"Discover URLs from a site using Firecrawl Map.",
|
|
187
188
|
promptSnippet: "Map site URLs",
|
|
188
189
|
promptGuidelines: [
|
|
189
190
|
"Use web_map to discover URLs from a site before crawling or targeted extraction.",
|
|
@@ -213,7 +214,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
213
214
|
if (params.use_index !== undefined) body.useIndex = params.use_index;
|
|
214
215
|
if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
|
|
215
216
|
const config = loadFirecrawlConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
216
|
-
const result = await firecrawlRequest(config, "POST", "/map", body,
|
|
217
|
+
const result = await firecrawlRequest(config, "POST", "/map", body, signal);
|
|
217
218
|
const urls = result.data || result.links || result.urls || [];
|
|
218
219
|
const text = Array.isArray(urls) && urls.length > 0
|
|
219
220
|
? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
|
|
@@ -227,7 +228,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
227
228
|
name: "web_crawl",
|
|
228
229
|
label: "Site Crawl",
|
|
229
230
|
description:
|
|
230
|
-
"Crawl
|
|
231
|
+
"Crawl site pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
|
|
231
232
|
promptSnippet: "Crawl a small site section",
|
|
232
233
|
promptGuidelines: [
|
|
233
234
|
"Use web_crawl only when multiple pages from a site are truly needed; prefer web_map + web_extract for small numbers of pages.",
|
|
@@ -288,14 +289,13 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
288
289
|
: [],
|
|
289
290
|
scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
|
|
290
291
|
},
|
|
291
|
-
true,
|
|
292
292
|
signal,
|
|
293
293
|
);
|
|
294
294
|
const id = result.id || (result.data as Record<string, unknown> | undefined)?.id;
|
|
295
295
|
if (params.poll && id && !Array.isArray(result.data)) {
|
|
296
296
|
const { abortableSleep } = await import("./lib/retry");
|
|
297
297
|
for (let i = 0; i < 60; i++) {
|
|
298
|
-
result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined,
|
|
298
|
+
result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, signal);
|
|
299
299
|
if (["completed", "failed", "cancelled"].includes(
|
|
300
300
|
String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""),
|
|
301
301
|
)) break;
|
|
@@ -319,7 +319,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
319
319
|
name: "web_screenshot",
|
|
320
320
|
label: "Web Page Screenshot",
|
|
321
321
|
description:
|
|
322
|
-
"Capture a full-page PNG screenshot
|
|
322
|
+
"Capture a full-page PNG screenshot via Crawl4AI headless browser.",
|
|
323
323
|
promptSnippet: "Screenshot a webpage",
|
|
324
324
|
promptGuidelines: [
|
|
325
325
|
"Use web_screenshot when a visual snapshot of a rendered page is needed, or when web_extract fails on a heavily JS-dependent or bot-protected page.",
|
|
@@ -360,7 +360,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
360
360
|
name: "web_pdf",
|
|
361
361
|
label: "Web Page PDF",
|
|
362
362
|
description:
|
|
363
|
-
"Generate a PDF document
|
|
363
|
+
"Generate a PDF document via Crawl4AI headless browser.",
|
|
364
364
|
promptSnippet: "PDF a webpage",
|
|
365
365
|
promptGuidelines: [
|
|
366
366
|
"Use web_pdf when a printable or archivable snapshot of a page is needed.",
|
|
@@ -389,13 +389,14 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
389
389
|
name: "web_status",
|
|
390
390
|
label: "Web Provider Status",
|
|
391
391
|
description:
|
|
392
|
-
"Show
|
|
392
|
+
"Show web provider configuration status without printing secrets.",
|
|
393
393
|
promptSnippet: "Check web provider configuration and server status",
|
|
394
394
|
promptGuidelines: [
|
|
395
395
|
"Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
|
|
396
396
|
"The output shows which backends are configured; if a backend is missing, configure its env vars.",
|
|
397
397
|
"Never print API key values; this tool reports only presence and source.",
|
|
398
398
|
"Shows Crawl4AI server health, version, and auth status when the server is reachable.",
|
|
399
|
+
"For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth. Use the `ready` field to check whether Firecrawl is actually usable.",
|
|
399
400
|
],
|
|
400
401
|
parameters: Type.Object({}),
|
|
401
402
|
async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
@@ -410,15 +411,19 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
410
411
|
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
411
412
|
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
412
413
|
|
|
414
|
+
const fcBaseUrl = normalizeFirecrawlBaseUrl(fireUrl.value);
|
|
415
|
+
const fcHosted = !fireUrl.value || fcBaseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
416
|
+
|
|
413
417
|
const status: Record<string, unknown> = {
|
|
414
418
|
brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
|
|
415
419
|
searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" },
|
|
416
420
|
firecrawl: {
|
|
417
|
-
baseUrl:
|
|
421
|
+
baseUrl: fcBaseUrl,
|
|
418
422
|
apiUrlSource: fireUrl.source || "default hosted",
|
|
419
423
|
apiKeyFound: Boolean(fireKey.value),
|
|
420
424
|
apiKeySource: fireKey.value ? fireKey.source : "not set",
|
|
421
|
-
hostedMode:
|
|
425
|
+
hostedMode: fcHosted,
|
|
426
|
+
ready: fcHosted ? Boolean(fireKey.value) : Boolean(fireUrl.value?.trim()),
|
|
422
427
|
},
|
|
423
428
|
crawl4ai: {
|
|
424
429
|
baseUrl: normalizeCrawl4aiApiUrl(c4aiUrl.value),
|
|
@@ -442,29 +447,4 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
442
447
|
},
|
|
443
448
|
});
|
|
444
449
|
|
|
445
|
-
// ── /web-status command ──────────────────────────────────────────────
|
|
446
|
-
pi.registerCommand("web-status", {
|
|
447
|
-
description: "Show web provider configuration status",
|
|
448
|
-
handler: async (_args: string, ctx: any) => {
|
|
449
|
-
const cwd = cwdFromContext(ctx);
|
|
450
|
-
const trusted = includeProjectEnv(ctx);
|
|
451
|
-
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
452
|
-
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
453
|
-
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
454
|
-
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
455
|
-
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
456
|
-
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
457
|
-
ctx.ui.notify(
|
|
458
|
-
[
|
|
459
|
-
`Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
|
|
460
|
-
`SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
|
|
461
|
-
`Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
|
|
462
|
-
`Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
|
|
463
|
-
`Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
|
|
464
|
-
`Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
|
|
465
|
-
].join("\n"),
|
|
466
|
-
"info",
|
|
467
|
-
);
|
|
468
|
-
},
|
|
469
|
-
});
|
|
470
450
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Brave Search API client.
|
|
2
|
+
|
|
3
|
+
import { sanitizeSnippet, type SearchResultItem } from "./format";
|
|
4
|
+
|
|
5
|
+
export interface BraveResult extends SearchResultItem {
|
|
6
|
+
age: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function fetchBraveResults(
|
|
10
|
+
query: string,
|
|
11
|
+
count: number,
|
|
12
|
+
country: string,
|
|
13
|
+
freshness: string,
|
|
14
|
+
apiKey: string,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<BraveResult[]> {
|
|
17
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
const params = new URLSearchParams({ q: query, count: String(count), country });
|
|
20
|
+
if (freshness) params.append("freshness", freshness);
|
|
21
|
+
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
22
|
+
headers: {
|
|
23
|
+
Accept: "application/json",
|
|
24
|
+
"Accept-Encoding": "gzip",
|
|
25
|
+
"X-Subscription-Token": apiKey,
|
|
26
|
+
},
|
|
27
|
+
signal,
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}\n${await response.text()}`);
|
|
30
|
+
const data: any = await response.json();
|
|
31
|
+
return (data.web?.results || [])
|
|
32
|
+
.slice(0, count)
|
|
33
|
+
.map((r: any) => ({
|
|
34
|
+
title: sanitizeSnippet(r.title || ""),
|
|
35
|
+
url: r.url || "",
|
|
36
|
+
snippet: sanitizeSnippet(r.description || ""),
|
|
37
|
+
age: sanitizeSnippet(r.age || r.page_age || ""),
|
|
38
|
+
}));
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (attempt === 2) throw e;
|
|
41
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error("unreachable");
|
|
45
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ponytail: duplicated in pi-munin/lib/helpers.ts. Extract when a third package needs it.
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
@@ -8,8 +8,6 @@ import path from "node:path";
|
|
|
8
8
|
// Constants
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
export const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
12
|
-
export const OUTPUT_MAX_LINES = 2_000;
|
|
13
11
|
export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
14
12
|
export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
|
|
15
13
|
export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
|
|
@@ -183,7 +181,6 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
|
|
|
183
181
|
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
184
182
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
185
183
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
186
|
-
if (!isHosted && apiKey && !explicitApiKey) throw new Error("Refusing to send a configured FIRECRAWL_API_KEY to a custom Firecrawl URL. Pass firecrawl_api_key explicitly for that URL.");
|
|
187
184
|
if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
|
|
188
185
|
return { baseUrl, apiKey, isHosted, timeoutMs };
|
|
189
186
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
|
|
4
4
|
|
|
5
5
|
import type { Crawl4aiConfig } from "./config";
|
|
6
|
-
import { signalWithTimeout
|
|
6
|
+
import { signalWithTimeout } from "./retry";
|
|
7
7
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Error
|
|
@@ -52,9 +52,15 @@ export async function crawl4aiRequest(
|
|
|
52
52
|
body?: unknown,
|
|
53
53
|
signal?: AbortSignal,
|
|
54
54
|
): Promise<Record<string, unknown>> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
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");
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
// ---------------------------------------------------------------------------
|
|
@@ -142,36 +148,6 @@ export async function fetchCrawl4aiCrawl(
|
|
|
142
148
|
};
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
/**
|
|
146
|
-
* Crawl one or more URLs via POST /crawl/stream.
|
|
147
|
-
* Returns newline-delimited JSON lines as a string.
|
|
148
|
-
*/
|
|
149
|
-
export async function fetchCrawl4aiStream(
|
|
150
|
-
config: Crawl4aiConfig,
|
|
151
|
-
urls: string[],
|
|
152
|
-
browserConfig?: Record<string, unknown>,
|
|
153
|
-
crawlerConfig?: Record<string, unknown>,
|
|
154
|
-
signal?: AbortSignal,
|
|
155
|
-
): Promise<string> {
|
|
156
|
-
const body: Record<string, unknown> = { urls };
|
|
157
|
-
if (browserConfig) body.browser_config = browserConfig;
|
|
158
|
-
if (crawlerConfig) body.crawler_config = crawlerConfig;
|
|
159
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
160
|
-
if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
|
|
161
|
-
headers["Content-Type"] = "application/json";
|
|
162
|
-
const response = await fetch(`${config.baseUrl}/crawl/stream`, {
|
|
163
|
-
method: "POST",
|
|
164
|
-
headers,
|
|
165
|
-
body: JSON.stringify(body),
|
|
166
|
-
signal: signalWithTimeout(config.timeoutMs, signal),
|
|
167
|
-
});
|
|
168
|
-
if (!response.ok) {
|
|
169
|
-
const text = await response.text();
|
|
170
|
-
throw new Crawl4aiHttpError(response.status, response.statusText, text);
|
|
171
|
-
}
|
|
172
|
-
return await response.text();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
151
|
/**
|
|
176
152
|
* Capture a screenshot via POST /screenshot.
|
|
177
153
|
*/
|
|
@@ -206,17 +182,7 @@ export async function fetchCrawl4aiHealth(
|
|
|
206
182
|
config: Crawl4aiConfig,
|
|
207
183
|
signal?: AbortSignal,
|
|
208
184
|
): Promise<{ status?: string; version?: string; timestamp?: number }> {
|
|
209
|
-
const
|
|
210
|
-
if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
|
|
211
|
-
const timeoutMs = Math.min(config.timeoutMs, 10000);
|
|
212
|
-
const response = await fetch(`${config.baseUrl}/health`, {
|
|
213
|
-
method: "GET",
|
|
214
|
-
headers,
|
|
215
|
-
signal: signalWithTimeout(timeoutMs, signal),
|
|
216
|
-
});
|
|
217
|
-
const text = await response.text();
|
|
218
|
-
if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
|
|
219
|
-
const data = text ? JSON.parse(text) : {};
|
|
185
|
+
const data = await crawl4aiRequest({ ...config, timeoutMs: Math.min(config.timeoutMs, 10000) }, "GET", "/health", undefined, signal);
|
|
220
186
|
return {
|
|
221
187
|
status: data.status as string | undefined,
|
|
222
188
|
version: data.version as string | undefined,
|
|
@@ -111,7 +111,7 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
|
|
|
111
111
|
...(params.wait_for ? { waitFor: params.wait_for } : {}),
|
|
112
112
|
...(params.mobile ? { mobile: true } : {}),
|
|
113
113
|
};
|
|
114
|
-
const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body,
|
|
114
|
+
const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
|
|
115
115
|
const structured = findStructuredPayload(result);
|
|
116
116
|
const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
|
|
117
117
|
const titleMatch = raw.match(/^# (.+)$/m);
|
|
@@ -183,7 +183,3 @@ export async function extractWithDiagnostics(params: ExtractParams): Promise<Ext
|
|
|
183
183
|
);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/** Compatibility wrapper returning only the selected extraction result. */
|
|
187
|
-
export async function universalExtract(params: ExtractParams): Promise<ExtractResult> {
|
|
188
|
-
return (await extractWithDiagnostics(params)).result;
|
|
189
|
-
}
|
|
@@ -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
|
|
4
|
+
import { signalWithTimeout } from "./retry";
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Types
|
|
@@ -53,34 +53,22 @@ async function firecrawlRequestJson(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Firecrawl API request with
|
|
57
|
-
* Returns the response data and optionally logs fallback via console.warn.
|
|
56
|
+
* Firecrawl API request with retry.
|
|
58
57
|
*/
|
|
59
58
|
export async function firecrawlRequest(
|
|
60
59
|
config: FirecrawlConfig,
|
|
61
60
|
method: string,
|
|
62
61
|
endpoint: string,
|
|
63
62
|
body?: unknown,
|
|
64
|
-
allowFallback = true,
|
|
65
63
|
signal?: AbortSignal,
|
|
66
64
|
): Promise<Record<string, unknown>> {
|
|
67
|
-
|
|
65
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
68
66
|
try {
|
|
69
67
|
return await firecrawlRequestJson(config, method, endpoint, body, signal);
|
|
70
68
|
} catch (e) {
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
config.isHosted ||
|
|
74
|
-
!config.baseUrl.endsWith("/v2") ||
|
|
75
|
-
!(e instanceof FirecrawlHttpError) ||
|
|
76
|
-
e.status !== 404
|
|
77
|
-
)
|
|
78
|
-
throw e;
|
|
79
|
-
const v1Config = { ...config, baseUrl: config.baseUrl.replace(/\/v2$/, "/v1") };
|
|
80
|
-
console.warn(
|
|
81
|
-
`[pi-web] Firecrawl v2 endpoint returned 404, falling back to v1 at ${v1Config.baseUrl}`,
|
|
82
|
-
);
|
|
83
|
-
return await firecrawlRequestJson(v1Config, method, endpoint, body, signal);
|
|
69
|
+
if (attempt === 2) throw e;
|
|
70
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
84
71
|
}
|
|
85
|
-
}
|
|
72
|
+
}
|
|
73
|
+
throw new Error("unreachable");
|
|
86
74
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Text formatting and result formatting helpers for pi-web.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
4
|
+
const OUTPUT_MAX_LINES = 2_000;
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Search result item types
|
|
@@ -47,28 +48,6 @@ export function truncateText(text: string): string {
|
|
|
47
48
|
return output;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
// Search result formatting
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
|
|
54
|
-
export function formatSearchResults(results: SearchResultItem[]): string {
|
|
55
|
-
if (!results.length) return "No results found.";
|
|
56
|
-
return results
|
|
57
|
-
.map((r, i) =>
|
|
58
|
-
[
|
|
59
|
-
`--- Result ${i + 1} ---`,
|
|
60
|
-
`Title: ${r.title || ""}`,
|
|
61
|
-
`Link: ${r.url || ""}`,
|
|
62
|
-
r.age ? `Age: ${r.age}` : "",
|
|
63
|
-
r.snippet || r.description ? `Snippet: ${r.snippet || r.description}` : "",
|
|
64
|
-
r.content || r.markdown ? `Content:\n${r.content || r.markdown}` : "",
|
|
65
|
-
]
|
|
66
|
-
.filter(Boolean)
|
|
67
|
-
.join("\n"),
|
|
68
|
-
)
|
|
69
|
-
.join("\n\n");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
51
|
// ---------------------------------------------------------------------------
|
|
73
52
|
// Firecrawl scrape formatting
|
|
74
53
|
// ---------------------------------------------------------------------------
|
|
@@ -175,51 +154,3 @@ export function formatCrawl4aiResult(data: Record<string, unknown>, maxChars = 2
|
|
|
175
154
|
.join("\n\n---\n\n") || "(No data)";
|
|
176
155
|
}
|
|
177
156
|
|
|
178
|
-
export function formatCrawl4aiStream(lines: string, _maxChars = 20000): string {
|
|
179
|
-
const results: string[] = [];
|
|
180
|
-
for (const line of lines.split("\n")) {
|
|
181
|
-
const trimmed = line.trim();
|
|
182
|
-
if (!trimmed) continue;
|
|
183
|
-
try {
|
|
184
|
-
const parsed = JSON.parse(trimmed);
|
|
185
|
-
if (parsed.status === "completed") {
|
|
186
|
-
results.push("[Stream complete]");
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
results.push(formatCrawl4aiResult(parsed, _maxChars));
|
|
190
|
-
} catch {
|
|
191
|
-
results.push(trimmed.slice(0, 1000));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return results.join("\n\n---\n\n");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function formatFirecrawlSearch(data: Record<string, unknown>, maxChars = 5000): string {
|
|
198
|
-
const rootData = data.data as Record<string, unknown> | undefined;
|
|
199
|
-
const web = (rootData?.web as unknown[]) || [];
|
|
200
|
-
const news = (rootData?.news as unknown[]) || [];
|
|
201
|
-
const images = (rootData?.images as unknown[]) || [];
|
|
202
|
-
const results: SearchResultItem[] = [...web, ...news].map((r: any) => ({
|
|
203
|
-
title: r.title || r.metadata?.title || "",
|
|
204
|
-
url: r.url || r.metadata?.sourceURL || "",
|
|
205
|
-
snippet: r.description || r.snippet || "",
|
|
206
|
-
markdown: r.markdown ? String(r.markdown).slice(0, maxChars) : "",
|
|
207
|
-
}));
|
|
208
|
-
let text = formatSearchResults(results);
|
|
209
|
-
if (images.length) {
|
|
210
|
-
text +=
|
|
211
|
-
"\n\n" +
|
|
212
|
-
images
|
|
213
|
-
.map((img: any, i: number) =>
|
|
214
|
-
[
|
|
215
|
-
`--- Image ${i + 1} ---`,
|
|
216
|
-
`Title: ${img.title || ""}`,
|
|
217
|
-
`Image: ${img.imageUrl || ""}`,
|
|
218
|
-
`Page: ${img.url || ""}`,
|
|
219
|
-
].join("\n"),
|
|
220
|
-
)
|
|
221
|
-
.join("\n\n");
|
|
222
|
-
}
|
|
223
|
-
if (data.warning) text += `\n\nWarning: ${data.warning}`;
|
|
224
|
-
return text;
|
|
225
|
-
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Retry utility: signal/timeout helpers.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combine a timeout signal with an optional external signal.
|
|
5
|
+
*/
|
|
6
|
+
export function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
7
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
8
|
+
if (!signal) return timeoutSignal;
|
|
9
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sleep that is abortable via an AbortSignal.
|
|
14
|
+
*/
|
|
15
|
+
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
16
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Operation aborted."));
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timer = setTimeout(cleanupResolve, ms);
|
|
19
|
+
function cleanupResolve() {
|
|
20
|
+
signal?.removeEventListener("abort", cleanupReject);
|
|
21
|
+
resolve();
|
|
22
|
+
}
|
|
23
|
+
function cleanupReject() {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
reject(signal?.reason ?? new Error("Operation aborted."));
|
|
26
|
+
}
|
|
27
|
+
signal?.addEventListener("abort", cleanupReject, { once: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -144,7 +144,7 @@ async function searchBrave(params: SearchParams, backends: BackendConfig): Promi
|
|
|
144
144
|
|
|
145
145
|
async function searchFirecrawl(params: SearchParams, backends: BackendConfig): Promise<SearchResult[]> {
|
|
146
146
|
if (!backends.firecrawl.configured || !backends.firecrawl.config) {
|
|
147
|
-
throw new Error("Firecrawl not configured (no
|
|
147
|
+
throw new Error("Firecrawl not configured (no FIRECRAWL_API_URL)");
|
|
148
148
|
}
|
|
149
149
|
const body: Record<string, unknown> = {
|
|
150
150
|
query: params.query,
|
|
@@ -152,7 +152,7 @@ async function searchFirecrawl(params: SearchParams, backends: BackendConfig): P
|
|
|
152
152
|
sources: [{ type: "web" }],
|
|
153
153
|
...(params.country ? { country: String(params.country).toUpperCase() } : {}),
|
|
154
154
|
};
|
|
155
|
-
const result = await firecrawlRequest(backends.firecrawl.config, "POST", "/search", body,
|
|
155
|
+
const result = await firecrawlRequest(backends.firecrawl.config, "POST", "/search", body, params.signal) as FirecrawlResult;
|
|
156
156
|
const rootData = result.data as Record<string, unknown> | undefined;
|
|
157
157
|
const web = (rootData?.web as unknown[]) || [];
|
|
158
158
|
const news = (rootData?.news as unknown[]) || [];
|
|
@@ -238,7 +238,3 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
|
|
|
238
238
|
);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Compatibility wrapper returning only results. */
|
|
242
|
-
export async function universalSearch(params: SearchParams): Promise<SearchResult[]> {
|
|
243
|
-
return (await searchWithDiagnostics(params)).results;
|
|
244
|
-
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SearXNG metasearch client.
|
|
2
|
+
|
|
3
|
+
import { sanitizeSnippet } from "./format";
|
|
4
|
+
import { signalWithTimeout } from "./retry";
|
|
5
|
+
|
|
6
|
+
export interface SearxngResultItem {
|
|
7
|
+
title: string;
|
|
8
|
+
url: string;
|
|
9
|
+
snippet: string;
|
|
10
|
+
engine: string;
|
|
11
|
+
category: string;
|
|
12
|
+
score?: number;
|
|
13
|
+
publishedDate: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SearxngResponse {
|
|
17
|
+
results: SearxngResultItem[];
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function fetchSearxngResults(
|
|
22
|
+
params: Record<string, unknown>,
|
|
23
|
+
baseUrl: string,
|
|
24
|
+
signal?: AbortSignal,
|
|
25
|
+
): Promise<SearxngResponse> {
|
|
26
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
const limit = Math.min(50, Math.max(1, (params.count as number) ?? 5));
|
|
29
|
+
const url = new URL(`${baseUrl}/search`);
|
|
30
|
+
url.searchParams.set("q", params.query as string);
|
|
31
|
+
url.searchParams.set("format", "json");
|
|
32
|
+
url.searchParams.set("pageno", String(Math.max(1, (params.pageno as number) ?? 1)));
|
|
33
|
+
for (const key of ["categories", "engines", "language", "time_range", "safesearch"] as const) {
|
|
34
|
+
const val = params[key];
|
|
35
|
+
if (val !== undefined && val !== "") url.searchParams.set(key, String(val));
|
|
36
|
+
}
|
|
37
|
+
const timeoutMs = (params.timeout_ms as number) || 15000;
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
headers: { Accept: "application/json", "User-Agent": "pi-web/0.1 SearXNG" },
|
|
40
|
+
signal: signalWithTimeout(timeoutMs, signal),
|
|
41
|
+
});
|
|
42
|
+
const text = await response.text();
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const hint =
|
|
45
|
+
response.status === 403
|
|
46
|
+
? "\nHint: enable JSON output in SearXNG settings.yml with search.formats including json."
|
|
47
|
+
: "";
|
|
48
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}${hint}${text ? `\n${text}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
const data: any = text ? JSON.parse(text) : {};
|
|
51
|
+
const results = (data.results || [])
|
|
52
|
+
.slice(0, limit)
|
|
53
|
+
.map((r: any) => ({
|
|
54
|
+
title: sanitizeSnippet(r.title || ""),
|
|
55
|
+
url: r.url || "",
|
|
56
|
+
snippet: sanitizeSnippet(r.content || r.snippet || ""),
|
|
57
|
+
engine: r.engine || "",
|
|
58
|
+
category: r.category || "",
|
|
59
|
+
score: r.score,
|
|
60
|
+
publishedDate: r.publishedDate || r.published_date || "",
|
|
61
|
+
}));
|
|
62
|
+
return { ...data, results };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (attempt === 2) throw e;
|
|
65
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error("unreachable");
|
|
69
|
+
}
|