@bacnh85/pi-web 0.4.4 → 0.4.6
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 +60 -75
- package/extensions/lib/brave.ts +23 -28
- package/extensions/lib/config.ts +17 -21
- package/extensions/lib/firecrawl.ts +2 -10
- package/extensions/lib/retry.ts +13 -0
- package/extensions/lib/searxng.ts +37 -43
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -38,15 +38,13 @@ const sharedControlSchema = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
const firecrawlControlSchema = {
|
|
41
|
-
firecrawl_api_key: Type.Optional(Type.String({ description: "
|
|
42
|
-
firecrawl_api_url: Type.Optional(Type.String({ description: "
|
|
43
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
41
|
+
firecrawl_api_key: Type.Optional(Type.String({ description: "Override $FIRECRAWL_API_KEY." })),
|
|
42
|
+
firecrawl_api_url: Type.Optional(Type.String({ description: "Override $FIRECRAWL_API_URL." })),
|
|
44
43
|
};
|
|
45
44
|
|
|
46
45
|
const crawl4aiControlSchema = {
|
|
47
|
-
crawl4ai_api_url: Type.Optional(Type.String({ description: "
|
|
48
|
-
crawl4ai_api_token: Type.Optional(Type.String({ description: "
|
|
49
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
46
|
+
crawl4ai_api_url: Type.Optional(Type.String({ description: "Override $CRAWL4AI_API_URL." })),
|
|
47
|
+
crawl4ai_api_token: Type.Optional(Type.String({ description: "Override $CRAWL4AI_API_TOKEN." })),
|
|
50
48
|
};
|
|
51
49
|
|
|
52
50
|
|
|
@@ -82,29 +80,26 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
82
80
|
name: "web_search",
|
|
83
81
|
label: "Web Search",
|
|
84
82
|
description:
|
|
85
|
-
"Search the web. Auto-selects backends
|
|
83
|
+
"Search the web. Auto-selects backends: SearXNG, Brave, Firecrawl.",
|
|
86
84
|
promptSnippet: "Search current web results",
|
|
87
85
|
promptGuidelines: [
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"Use
|
|
92
|
-
"
|
|
93
|
-
"include_content prefers Brave automatically because only Brave search fetches inline page content; for other result URLs, use web_extract.",
|
|
94
|
-
"Note: Firecrawl Search has poor semantic accuracy on domain-specific/ambiguous queries; prefer SearXNG or Brave for precision.",
|
|
95
|
-
"Cite result URLs when web results materially support the answer.",
|
|
86
|
+
"Source discovery, docs, facts, and general search.",
|
|
87
|
+
"Broad discovery uses SearXNG; precision/site/docs use Brave; Firecrawl is fallback.",
|
|
88
|
+
"Force backend via backend:'brave'|'searxng' for poor auto results.",
|
|
89
|
+
"Use engines='google,github' for SearXNG tuning.",
|
|
90
|
+
"Cite source URLs.",
|
|
96
91
|
],
|
|
97
92
|
parameters: Type.Object({
|
|
98
93
|
query: Type.String(),
|
|
99
94
|
count: Type.Optional(Type.Number({ default: 5 })),
|
|
100
|
-
freshness: Type.Optional(Type.String({ description: "Time filter:
|
|
95
|
+
freshness: Type.Optional(Type.String({ description: "Time filter: pw/pm/py or YYYY-MM-DDtoYYYY-MM-DD." })),
|
|
101
96
|
country: Type.Optional(Type.String({ default: "US" })),
|
|
102
97
|
backend: Type.Optional(Type.Union(
|
|
103
98
|
[Type.Literal("auto"), Type.Literal("searxng"), Type.Literal("brave"), Type.Literal("firecrawl")],
|
|
104
|
-
{ default: "auto", description: "
|
|
99
|
+
{ default: "auto", description: "auto, searxng, brave, firecrawl." },
|
|
105
100
|
)),
|
|
106
|
-
engines: Type.Optional(Type.String({ description: "SearXNG engine
|
|
107
|
-
include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch page content
|
|
101
|
+
engines: Type.Optional(Type.String({ description: "SearXNG engine list (google,github). Only for searxng/auto backend." })),
|
|
102
|
+
include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch inline page content (slower)." })),
|
|
108
103
|
content_chars: Type.Optional(Type.Number({ default: 5000 })),
|
|
109
104
|
...sharedControlSchema,
|
|
110
105
|
}),
|
|
@@ -133,30 +128,26 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
133
128
|
name: "web_extract",
|
|
134
129
|
label: "Web Content Extraction",
|
|
135
130
|
description:
|
|
136
|
-
"Extract readable content from a URL. Auto
|
|
131
|
+
"Extract readable content from a URL. Auto mode: static\u2192dynamic\u2192full.",
|
|
137
132
|
promptSnippet: "Extract readable webpage content as markdown",
|
|
138
133
|
promptGuidelines: [
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"Use
|
|
143
|
-
"
|
|
144
|
-
"⚠️ Dynamic mode (Firecrawl Scrape) may fail on bot-protected sites (Ansible docs, CDN-backed doc sites). Full mode (Crawl4AI) handles more sites but is heavier.",
|
|
145
|
-
"If all extraction modes fail, try web_screenshot for a visual snapshot, or the page may require interactive login.",
|
|
146
|
-
"Use the prompt and schema parameters for structured JSON extraction (dynamic mode only); structured JSON is returned in details and text when available.",
|
|
147
|
-
"Cite the source URL when using extracted content in an answer.",
|
|
134
|
+
"Clean markdown from a known URL.",
|
|
135
|
+
"mode: 'static' (no API key, JSDOM), 'dynamic' (Firecrawl JS), 'full' (Crawl4AI).",
|
|
136
|
+
"'auto' tries static\u2192dynamic\u2192full; see diagnostics for fallback chain.",
|
|
137
|
+
"Use prompt+schema for structured JSON extraction (dynamic mode only).",
|
|
138
|
+
"Cite the source URL.",
|
|
148
139
|
],
|
|
149
140
|
parameters: Type.Object({
|
|
150
141
|
url: Type.String(),
|
|
151
142
|
mode: Type.Optional(Type.Union(
|
|
152
143
|
[Type.Literal("auto"), Type.Literal("static"), Type.Literal("dynamic"), Type.Literal("full")],
|
|
153
|
-
{ default: "auto", description: "
|
|
144
|
+
{ default: "auto", description: "auto, static, dynamic, full." },
|
|
154
145
|
)),
|
|
155
146
|
prompt: Type.Optional(Type.String({ description: "Prompt for structured JSON extraction (dynamic mode only)." })),
|
|
156
147
|
schema: Type.Optional(Type.Any({ description: "JSON schema for structured extraction (dynamic mode only)." })),
|
|
157
148
|
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
158
|
-
wait_for: Type.Optional(Type.Number({ description: "
|
|
159
|
-
mobile: Type.Optional(Type.Boolean({ default: false, description: "
|
|
149
|
+
wait_for: Type.Optional(Type.Number({ description: "Ms to wait for Firecrawl render before extraction." })),
|
|
150
|
+
mobile: Type.Optional(Type.Boolean({ default: false, description: "Mobile viewport (dynamic mode only)." })),
|
|
160
151
|
...sharedControlSchema,
|
|
161
152
|
}),
|
|
162
153
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
@@ -184,24 +175,23 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
184
175
|
name: "web_map",
|
|
185
176
|
label: "Site URL Discovery",
|
|
186
177
|
description:
|
|
187
|
-
"Discover URLs
|
|
178
|
+
"Discover site URLs via Firecrawl Map.",
|
|
188
179
|
promptSnippet: "Map site URLs",
|
|
189
180
|
promptGuidelines: [
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"Use mapped URLs with web_extract for targeted extraction instead of web_crawl when only a few pages are needed.",
|
|
194
|
-
"Keep limits small unless broad site discovery is explicitly requested.",
|
|
181
|
+
"Discover site URLs before crawling. Prefer web_extract for small jobs.",
|
|
182
|
+
"Best on base domains. sitemap:'only' for sub-path discovery.",
|
|
183
|
+
"Keep limits small unless broad discovery is requested.",
|
|
195
184
|
],
|
|
196
185
|
parameters: Type.Object({
|
|
197
186
|
url: Type.String(),
|
|
198
187
|
limit: Type.Optional(Type.Number({ default: 100 })),
|
|
199
188
|
include_subdomains: Type.Optional(Type.Boolean({ default: false })),
|
|
200
|
-
search: Type.Optional(Type.String({ description: "
|
|
201
|
-
sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "
|
|
202
|
-
use_index: Type.Optional(Type.Boolean({ default: true, description: "
|
|
203
|
-
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached
|
|
189
|
+
search: Type.Optional(Type.String({ description: "Search query to guide URL discovery (semantic map)." })),
|
|
190
|
+
sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "only, include(default), skip." })),
|
|
191
|
+
use_index: Type.Optional(Type.Boolean({ default: true, description: "Use Firecrawl index for discovery." })),
|
|
192
|
+
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached results." })),
|
|
204
193
|
...firecrawlControlSchema,
|
|
194
|
+
...sharedControlSchema,
|
|
205
195
|
}),
|
|
206
196
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
207
197
|
const body: Record<string, unknown> = {
|
|
@@ -228,30 +218,27 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
228
218
|
name: "web_crawl",
|
|
229
219
|
label: "Site Crawl",
|
|
230
220
|
description:
|
|
231
|
-
"Crawl
|
|
221
|
+
"Crawl pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
|
|
232
222
|
promptSnippet: "Crawl a small site section",
|
|
233
223
|
promptGuidelines: [
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
"For Firecrawl mode ('light'), use the url parameter with optional include_paths/exclude_paths.",
|
|
238
|
-
"For Crawl4AI mode ('full'), use the urls (array) parameter for batch crawling up to 100 URLs.",
|
|
239
|
-
"Keep limits conservative (default 10) unless the user explicitly requests large crawls.",
|
|
240
|
-
"Try web_map first to identify candidate URLs and pass them to web_extract before resorting to a full crawl.",
|
|
224
|
+
"Prefer web_map + web_extract over crawl for small jobs.",
|
|
225
|
+
"'light'=Firecrawl (url param), 'full'=Crawl4AI (urls[] param).",
|
|
226
|
+
"Keep limit low (default 10).",
|
|
241
227
|
],
|
|
242
228
|
parameters: Type.Object({
|
|
243
|
-
url: Type.Optional(Type.String({ description: "URL for Firecrawl-style
|
|
244
|
-
urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style
|
|
245
|
-
mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "
|
|
229
|
+
url: Type.Optional(Type.String({ description: "URL for Firecrawl-style crawl (mode:'light')." })),
|
|
230
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style crawl (mode:'full'), up to 100." })),
|
|
231
|
+
mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "'light'(Firecrawl) or 'full'(Crawl4AI)." })),
|
|
246
232
|
limit: Type.Optional(Type.Number({ default: 10 })),
|
|
247
|
-
include_paths: Type.Optional(Type.String({ description: "Comma-separated
|
|
248
|
-
exclude_paths: Type.Optional(Type.String({ description: "Comma-separated
|
|
249
|
-
poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for
|
|
250
|
-
browser_config: Type.Optional(Type.Any({ description: "
|
|
251
|
-
crawler_config: Type.Optional(Type.Any({ description: "
|
|
233
|
+
include_paths: Type.Optional(Type.String({ description: "Comma-separated paths to include (Firecrawl mode)." })),
|
|
234
|
+
exclude_paths: Type.Optional(Type.String({ description: "Comma-separated paths to exclude (Firecrawl mode)." })),
|
|
235
|
+
poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for completion (Firecrawl mode)." })),
|
|
236
|
+
browser_config: Type.Optional(Type.Any({ description: "BrowserConfig JSON (full mode only)." })),
|
|
237
|
+
crawler_config: Type.Optional(Type.Any({ description: "CrawlerRunConfig JSON (full mode only)." })),
|
|
252
238
|
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
253
239
|
...firecrawlControlSchema,
|
|
254
240
|
...crawl4aiControlSchema,
|
|
241
|
+
...sharedControlSchema,
|
|
255
242
|
}),
|
|
256
243
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
257
244
|
const mode = (params.mode as string) || "light";
|
|
@@ -319,19 +306,18 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
319
306
|
name: "web_screenshot",
|
|
320
307
|
label: "Web Page Screenshot",
|
|
321
308
|
description:
|
|
322
|
-
"
|
|
309
|
+
"Full-page PNG screenshot via Crawl4AI.",
|
|
323
310
|
promptSnippet: "Screenshot a webpage",
|
|
324
311
|
promptGuidelines: [
|
|
325
|
-
"Use
|
|
326
|
-
"
|
|
327
|
-
"Use wait_for to delay capture for dynamic content to render (default 2 seconds).",
|
|
328
|
-
"Use wait_for_images to ensure images are loaded before capture.",
|
|
312
|
+
"Use when web_extract fails on JS-heavy or bot-protected pages.",
|
|
313
|
+
"wait_for (default 2s) delays capture for dynamic content.",
|
|
329
314
|
],
|
|
330
315
|
parameters: Type.Object({
|
|
331
316
|
url: Type.String(),
|
|
332
|
-
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before
|
|
333
|
-
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images
|
|
317
|
+
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capture." })),
|
|
318
|
+
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images before capture." })),
|
|
334
319
|
...crawl4aiControlSchema,
|
|
320
|
+
...sharedControlSchema,
|
|
335
321
|
}),
|
|
336
322
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
337
323
|
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
@@ -360,15 +346,16 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
360
346
|
name: "web_pdf",
|
|
361
347
|
label: "Web Page PDF",
|
|
362
348
|
description:
|
|
363
|
-
"
|
|
349
|
+
"PDF document via Crawl4AI.",
|
|
364
350
|
promptSnippet: "PDF a webpage",
|
|
365
351
|
promptGuidelines: [
|
|
366
|
-
"
|
|
367
|
-
"
|
|
352
|
+
"Printable or archivable page snapshot.",
|
|
353
|
+
"Returns base64 PDF string.",
|
|
368
354
|
],
|
|
369
355
|
parameters: Type.Object({
|
|
370
356
|
url: Type.String(),
|
|
371
357
|
...crawl4aiControlSchema,
|
|
358
|
+
...sharedControlSchema,
|
|
372
359
|
}),
|
|
373
360
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
374
361
|
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
@@ -389,14 +376,12 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
389
376
|
name: "web_status",
|
|
390
377
|
label: "Web Provider Status",
|
|
391
378
|
description:
|
|
392
|
-
"Show web provider
|
|
393
|
-
promptSnippet: "Check web provider
|
|
379
|
+
"Show web provider config status without printing secrets.",
|
|
380
|
+
promptSnippet: "Check web provider config and server status",
|
|
394
381
|
promptGuidelines: [
|
|
395
|
-
"
|
|
396
|
-
"
|
|
397
|
-
"
|
|
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.",
|
|
382
|
+
"Check which backends are configured and their server status.",
|
|
383
|
+
"Never prints secrets — reports only presence and source.",
|
|
384
|
+
"apiKeyFound:false is normal for self-hosted Firecrawl; check ready field.",
|
|
400
385
|
],
|
|
401
386
|
parameters: Type.Object({}),
|
|
402
387
|
async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
package/extensions/lib/brave.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Brave Search API client.
|
|
2
2
|
|
|
3
3
|
import { sanitizeSnippet, type SearchResultItem } from "./format";
|
|
4
|
+
import { withRetry } from "./retry";
|
|
4
5
|
|
|
5
6
|
export interface BraveResult extends SearchResultItem {
|
|
6
7
|
age: string;
|
|
@@ -14,32 +15,26 @@ export async function fetchBraveResults(
|
|
|
14
15
|
apiKey: string,
|
|
15
16
|
signal?: AbortSignal,
|
|
16
17
|
): Promise<BraveResult[]> {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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");
|
|
18
|
+
return withRetry(async () => {
|
|
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
|
+
});
|
|
45
40
|
}
|
package/extensions/lib/config.ts
CHANGED
|
@@ -87,6 +87,20 @@ export function includeProjectEnv(ctx: Record<string, unknown>): boolean {
|
|
|
87
87
|
return typeof ctx?.isProjectTrusted === "function" ? (ctx.isProjectTrusted as () => boolean)() : false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// URL helpers
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function normalizeHttpUrl(raw: string | undefined, fallback: string): string {
|
|
95
|
+
let value = (raw || fallback).trim();
|
|
96
|
+
if (!/^https?:\/\//i.test(value)) {
|
|
97
|
+
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
98
|
+
? `http://${value}`
|
|
99
|
+
: `https://${value}`;
|
|
100
|
+
}
|
|
101
|
+
return value.replace(/\/+$/, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
// ---------------------------------------------------------------------------
|
|
91
105
|
// SearXNG config
|
|
92
106
|
// ---------------------------------------------------------------------------
|
|
@@ -97,13 +111,7 @@ export interface SearxngConfig {
|
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
export function normalizeSearxngBaseUrl(raw?: string): string {
|
|
100
|
-
|
|
101
|
-
if (!/^https?:\/\//i.test(value)) {
|
|
102
|
-
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
103
|
-
? `http://${value}`
|
|
104
|
-
: `https://${value}`;
|
|
105
|
-
}
|
|
106
|
-
return value.replace(/\/+$/, "");
|
|
114
|
+
return normalizeHttpUrl(raw, DEFAULT_SEARXNG_BASE_URL);
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): SearxngConfig {
|
|
@@ -125,13 +133,7 @@ export interface Crawl4aiConfig {
|
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
export function normalizeCrawl4aiApiUrl(raw?: string): string {
|
|
128
|
-
|
|
129
|
-
if (!/^https?:\/\//i.test(value)) {
|
|
130
|
-
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
131
|
-
? `http://${value}`
|
|
132
|
-
: `https://${value}`;
|
|
133
|
-
}
|
|
134
|
-
return value.replace(/\/+$/, "");
|
|
136
|
+
return normalizeHttpUrl(raw, DEFAULT_CRAWL4AI_API_URL);
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
|
|
@@ -159,13 +161,7 @@ export interface FirecrawlConfig {
|
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
export function normalizeFirecrawlBaseUrl(raw?: string): string {
|
|
162
|
-
let value = (raw
|
|
163
|
-
if (!/^https?:\/\//i.test(value)) {
|
|
164
|
-
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
165
|
-
? `http://${value}`
|
|
166
|
-
: `https://${value}`;
|
|
167
|
-
}
|
|
168
|
-
value = value.replace(/\/+$/, "");
|
|
164
|
+
let value = normalizeHttpUrl(raw, HOSTED_FIRECRAWL_BASE_URL);
|
|
169
165
|
if (!/\/v\d+$/i.test(value)) value += "/v2";
|
|
170
166
|
return value;
|
|
171
167
|
}
|
|
@@ -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 } from "./retry";
|
|
4
|
+
import { signalWithTimeout, withRetry } from "./retry";
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Types
|
|
@@ -62,13 +62,5 @@ export async function firecrawlRequest(
|
|
|
62
62
|
body?: unknown,
|
|
63
63
|
signal?: AbortSignal,
|
|
64
64
|
): Promise<Record<string, unknown>> {
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
return await firecrawlRequestJson(config, method, endpoint, body, signal);
|
|
68
|
-
} catch (e) {
|
|
69
|
-
if (attempt === 2) throw e;
|
|
70
|
-
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
throw new Error("unreachable");
|
|
65
|
+
return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal));
|
|
74
66
|
}
|
package/extensions/lib/retry.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
// Retry utility: signal/timeout helpers.
|
|
2
2
|
|
|
3
|
+
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
|
4
|
+
let last: unknown;
|
|
5
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
6
|
+
try { return await fn(); }
|
|
7
|
+
catch (error) {
|
|
8
|
+
last = error;
|
|
9
|
+
if (attempt === attempts - 1) break;
|
|
10
|
+
await abortableSleep(1000 * 2 ** attempt);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
throw last ?? new Error("retry failed");
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
/**
|
|
4
17
|
* Combine a timeout signal with an optional external signal.
|
|
5
18
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SearXNG metasearch client.
|
|
2
2
|
|
|
3
3
|
import { sanitizeSnippet } from "./format";
|
|
4
|
-
import { signalWithTimeout } from "./retry";
|
|
4
|
+
import { signalWithTimeout, withRetry } from "./retry";
|
|
5
5
|
|
|
6
6
|
export interface SearxngResultItem {
|
|
7
7
|
title: string;
|
|
@@ -23,47 +23,41 @@ export async function fetchSearxngResults(
|
|
|
23
23
|
baseUrl: string,
|
|
24
24
|
signal?: AbortSignal,
|
|
25
25
|
): Promise<SearxngResponse> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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)));
|
|
26
|
+
return withRetry(async () => {
|
|
27
|
+
const limit = Math.min(50, Math.max(1, (params.count as number) ?? 5));
|
|
28
|
+
const url = new URL(`${baseUrl}/search`);
|
|
29
|
+
url.searchParams.set("q", params.query as string);
|
|
30
|
+
url.searchParams.set("format", "json");
|
|
31
|
+
url.searchParams.set("pageno", String(Math.max(1, (params.pageno as number) ?? 1)));
|
|
32
|
+
for (const key of ["categories", "engines", "language", "time_range", "safesearch"] as const) {
|
|
33
|
+
const val = params[key];
|
|
34
|
+
if (val !== undefined && val !== "") url.searchParams.set(key, String(val));
|
|
66
35
|
}
|
|
67
|
-
|
|
68
|
-
|
|
36
|
+
const timeoutMs = (params.timeout_ms as number) || 15000;
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
headers: { Accept: "application/json", "User-Agent": "pi-web/0.1 SearXNG" },
|
|
39
|
+
signal: signalWithTimeout(timeoutMs, signal),
|
|
40
|
+
});
|
|
41
|
+
const text = await response.text();
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const hint =
|
|
44
|
+
response.status === 403
|
|
45
|
+
? "\nHint: enable JSON output in SearXNG settings.yml with search.formats including json."
|
|
46
|
+
: "";
|
|
47
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}${hint}${text ? `\n${text}` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
const data: any = text ? JSON.parse(text) : {};
|
|
50
|
+
const results = (data.results || [])
|
|
51
|
+
.slice(0, limit)
|
|
52
|
+
.map((r: any) => ({
|
|
53
|
+
title: sanitizeSnippet(r.title || ""),
|
|
54
|
+
url: r.url || "",
|
|
55
|
+
snippet: sanitizeSnippet(r.content || r.snippet || ""),
|
|
56
|
+
engine: r.engine || "",
|
|
57
|
+
category: r.category || "",
|
|
58
|
+
score: r.score,
|
|
59
|
+
publishedDate: r.publishedDate || r.published_date || "",
|
|
60
|
+
}));
|
|
61
|
+
return { ...data, results };
|
|
62
|
+
});
|
|
69
63
|
}
|
package/package.json
CHANGED