@bacnh85/pi-web 0.4.3 → 0.4.5
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 +1 -25
- package/extensions/lib/brave.ts +23 -28
- package/extensions/lib/config.ts +18 -23
- 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
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -446,29 +447,4 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
446
447
|
},
|
|
447
448
|
});
|
|
448
449
|
|
|
449
|
-
// ── /web-status command ──────────────────────────────────────────────
|
|
450
|
-
pi.registerCommand("web-status", {
|
|
451
|
-
description: "Show web provider configuration status",
|
|
452
|
-
handler: async (_args: string, ctx: any) => {
|
|
453
|
-
const cwd = cwdFromContext(ctx);
|
|
454
|
-
const trusted = includeProjectEnv(ctx);
|
|
455
|
-
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
456
|
-
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
457
|
-
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
458
|
-
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
459
|
-
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
460
|
-
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
461
|
-
ctx.ui.notify(
|
|
462
|
-
[
|
|
463
|
-
`Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
|
|
464
|
-
`SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
|
|
465
|
-
`Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
|
|
466
|
-
`Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
|
|
467
|
-
`Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
|
|
468
|
-
`Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
|
|
469
|
-
].join("\n"),
|
|
470
|
-
"info",
|
|
471
|
-
);
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
450
|
}
|
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
|
@@ -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";
|
|
@@ -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
|
}
|
|
@@ -181,7 +177,6 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
|
|
|
181
177
|
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
182
178
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
183
179
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
184
|
-
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.");
|
|
185
180
|
if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
|
|
186
181
|
return { baseUrl, apiKey, isHosted, timeoutMs };
|
|
187
182
|
}
|
|
@@ -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