@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.
@@ -0,0 +1,5 @@
1
+ require:
2
+ - tsx
3
+ spec:
4
+ - test/**/*.test.ts
5
+ timeout: 10000
@@ -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 with adaptive backend selection. Uses SearXNG (self-hosted) for broad discovery, Brave (hosted API) for precision-sensitive queries and include_content, and Firecrawl as a last resort. Use backend to force a provider.",
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 with automatic backend selection. Tries static extraction (JSDOM+Readability, no API key) first, then dynamic extraction (Firecrawl Scrape, JS rendering), then full browser extraction (Crawl4AI, headless browser). Use the mode parameter for explicit control. If all modes fail, try web_screenshot for a visual snapshot.",
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. Best for finding candidate pages from a site or docs section before targeted extraction. Works best on base domains; may return empty results on sub-paths.",
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, true, signal);
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 one or more pages from a site. Uses Firecrawl Crawl in 'light' mode (conservative, doc-focused) or Crawl4AI in 'full' mode (headless browser, rendered data, media, links). Accepts a single URL for Firecrawl-style crawling or multiple URLs for Crawl4AI-style crawling.",
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, true, signal);
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 of a URL using Crawl4AI's headless browser. Returns a base64-encoded PNG image suitable for visual inspection.",
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 of a URL using Crawl4AI's headless browser. Returns a base64-encoded PDF suitable for saving or archiving.",
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 all web provider configuration status (Brave, SearXNG, Firecrawl, Crawl4AI) without printing secrets. Includes Crawl4AI server health check. Use when web tools fail due to missing credentials or configuration issues.",
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: normalizeFirecrawlBaseUrl(fireUrl.value),
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: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL),
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, withRetry } from "./retry";
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
- return withRetry(async () => {
56
- return await crawl4aiRequestJson(config, method, endpoint, body, signal);
57
- }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
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 headers: Record<string, string> = { Accept: "application/json" };
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, true, params.signal) as FirecrawlResult;
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, withRetry } from "./retry";
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 optional v2→v1 fallback on 404.
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
- return withRetry(async () => {
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
- !allowFallback ||
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
- }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
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
- import { OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES } from "./config";
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 FIRECRAWL_API_KEY/FIRECRAWL_API_URL)");
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, true, params.signal) as FirecrawlResult;
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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }