@bacnh85/pi-web 0.4.1 → 0.4.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/.mocharc.yml +5 -0
- package/{index.ts → extensions/index.ts} +16 -12
- package/extensions/lib/brave.ts +45 -0
- package/{lib → extensions/lib}/config.ts +0 -2
- 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
|
@@ -81,7 +81,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
81
81
|
name: "web_search",
|
|
82
82
|
label: "Web Search",
|
|
83
83
|
description:
|
|
84
|
-
"Search the web
|
|
84
|
+
"Search the web. Auto-selects backends adaptively: SearXNG, Brave, Firecrawl.",
|
|
85
85
|
promptSnippet: "Search current web results",
|
|
86
86
|
promptGuidelines: [
|
|
87
87
|
"Use web_search for source discovery, documentation lookup, current facts, and general web search.",
|
|
@@ -132,7 +132,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
132
132
|
name: "web_extract",
|
|
133
133
|
label: "Web Content Extraction",
|
|
134
134
|
description:
|
|
135
|
-
"Extract readable content from a URL
|
|
135
|
+
"Extract readable content from a URL. Auto backend: static \u2192 dynamic \u2192 full.",
|
|
136
136
|
promptSnippet: "Extract readable webpage content as markdown",
|
|
137
137
|
promptGuidelines: [
|
|
138
138
|
"Use web_extract to get clean markdown content from a known URL.",
|
|
@@ -183,7 +183,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
183
183
|
name: "web_map",
|
|
184
184
|
label: "Site URL Discovery",
|
|
185
185
|
description:
|
|
186
|
-
"Discover URLs from a site using Firecrawl Map.
|
|
186
|
+
"Discover URLs from a site using Firecrawl Map.",
|
|
187
187
|
promptSnippet: "Map site URLs",
|
|
188
188
|
promptGuidelines: [
|
|
189
189
|
"Use web_map to discover URLs from a site before crawling or targeted extraction.",
|
|
@@ -213,7 +213,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
213
213
|
if (params.use_index !== undefined) body.useIndex = params.use_index;
|
|
214
214
|
if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
|
|
215
215
|
const config = loadFirecrawlConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
216
|
-
const result = await firecrawlRequest(config, "POST", "/map", body,
|
|
216
|
+
const result = await firecrawlRequest(config, "POST", "/map", body, signal);
|
|
217
217
|
const urls = result.data || result.links || result.urls || [];
|
|
218
218
|
const text = Array.isArray(urls) && urls.length > 0
|
|
219
219
|
? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
|
|
@@ -227,7 +227,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
227
227
|
name: "web_crawl",
|
|
228
228
|
label: "Site Crawl",
|
|
229
229
|
description:
|
|
230
|
-
"Crawl
|
|
230
|
+
"Crawl site pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
|
|
231
231
|
promptSnippet: "Crawl a small site section",
|
|
232
232
|
promptGuidelines: [
|
|
233
233
|
"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 +288,13 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
288
288
|
: [],
|
|
289
289
|
scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
|
|
290
290
|
},
|
|
291
|
-
true,
|
|
292
291
|
signal,
|
|
293
292
|
);
|
|
294
293
|
const id = result.id || (result.data as Record<string, unknown> | undefined)?.id;
|
|
295
294
|
if (params.poll && id && !Array.isArray(result.data)) {
|
|
296
295
|
const { abortableSleep } = await import("./lib/retry");
|
|
297
296
|
for (let i = 0; i < 60; i++) {
|
|
298
|
-
result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined,
|
|
297
|
+
result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, signal);
|
|
299
298
|
if (["completed", "failed", "cancelled"].includes(
|
|
300
299
|
String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""),
|
|
301
300
|
)) break;
|
|
@@ -319,7 +318,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
319
318
|
name: "web_screenshot",
|
|
320
319
|
label: "Web Page Screenshot",
|
|
321
320
|
description:
|
|
322
|
-
"Capture a full-page PNG screenshot
|
|
321
|
+
"Capture a full-page PNG screenshot via Crawl4AI headless browser.",
|
|
323
322
|
promptSnippet: "Screenshot a webpage",
|
|
324
323
|
promptGuidelines: [
|
|
325
324
|
"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 +359,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
360
359
|
name: "web_pdf",
|
|
361
360
|
label: "Web Page PDF",
|
|
362
361
|
description:
|
|
363
|
-
"Generate a PDF document
|
|
362
|
+
"Generate a PDF document via Crawl4AI headless browser.",
|
|
364
363
|
promptSnippet: "PDF a webpage",
|
|
365
364
|
promptGuidelines: [
|
|
366
365
|
"Use web_pdf when a printable or archivable snapshot of a page is needed.",
|
|
@@ -389,13 +388,14 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
389
388
|
name: "web_status",
|
|
390
389
|
label: "Web Provider Status",
|
|
391
390
|
description:
|
|
392
|
-
"Show
|
|
391
|
+
"Show web provider configuration status without printing secrets.",
|
|
393
392
|
promptSnippet: "Check web provider configuration and server status",
|
|
394
393
|
promptGuidelines: [
|
|
395
394
|
"Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
|
|
396
395
|
"The output shows which backends are configured; if a backend is missing, configure its env vars.",
|
|
397
396
|
"Never print API key values; this tool reports only presence and source.",
|
|
398
397
|
"Shows Crawl4AI server health, version, and auth status when the server is reachable.",
|
|
398
|
+
"For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth. Use the `ready` field to check whether Firecrawl is actually usable.",
|
|
399
399
|
],
|
|
400
400
|
parameters: Type.Object({}),
|
|
401
401
|
async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
@@ -410,15 +410,19 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
410
410
|
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
411
411
|
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
412
412
|
|
|
413
|
+
const fcBaseUrl = normalizeFirecrawlBaseUrl(fireUrl.value);
|
|
414
|
+
const fcHosted = !fireUrl.value || fcBaseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
415
|
+
|
|
413
416
|
const status: Record<string, unknown> = {
|
|
414
417
|
brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
|
|
415
418
|
searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" },
|
|
416
419
|
firecrawl: {
|
|
417
|
-
baseUrl:
|
|
420
|
+
baseUrl: fcBaseUrl,
|
|
418
421
|
apiUrlSource: fireUrl.source || "default hosted",
|
|
419
422
|
apiKeyFound: Boolean(fireKey.value),
|
|
420
423
|
apiKeySource: fireKey.value ? fireKey.source : "not set",
|
|
421
|
-
hostedMode:
|
|
424
|
+
hostedMode: fcHosted,
|
|
425
|
+
ready: fcHosted ? Boolean(fireKey.value) : Boolean(fireUrl.value?.trim()),
|
|
422
426
|
},
|
|
423
427
|
crawl4ai: {
|
|
424
428
|
baseUrl: normalizeCrawl4aiApiUrl(c4aiUrl.value),
|
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -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
|
+
}
|