@bacnh85/pi-web 0.2.1 → 0.4.1
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/README.md +173 -61
- package/index.ts +289 -236
- package/lib/config.ts +34 -0
- package/lib/crawl4ai.ts +225 -0
- package/lib/extract.ts +189 -0
- package/lib/format.ts +104 -0
- package/lib/search.ts +244 -0
- package/package.json +5 -3
package/lib/search.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Unified search orchestrator.
|
|
2
|
+
// Probes backend availability and adaptively tries backends:
|
|
3
|
+
// SearXNG (self-hosted) for broad discovery,
|
|
4
|
+
// Brave (hosted API) for precision/inline content,
|
|
5
|
+
// Firecrawl as last-resort search.
|
|
6
|
+
|
|
7
|
+
import { findEnvValue, cwdFromContext, includeProjectEnv, normalizeSearxngBaseUrl } from "./config";
|
|
8
|
+
import { fetchBraveResults } from "./brave";
|
|
9
|
+
import { fetchSearxngResults } from "./searxng";
|
|
10
|
+
import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
|
|
11
|
+
import { loadFirecrawlConfig, loadSearxngConfig, type FirecrawlConfig } from "./config";
|
|
12
|
+
import { fetchReadableContent } from "./content";
|
|
13
|
+
import { sanitizeSnippet } from "./format";
|
|
14
|
+
|
|
15
|
+
export type SearchBackend = "searxng" | "brave" | "firecrawl";
|
|
16
|
+
export type SearchAttemptStatus = "skipped" | "success" | "empty" | "error";
|
|
17
|
+
|
|
18
|
+
export interface SearchParams {
|
|
19
|
+
query: string;
|
|
20
|
+
count?: number;
|
|
21
|
+
freshness?: string;
|
|
22
|
+
country?: string;
|
|
23
|
+
backend?: "auto" | SearchBackend;
|
|
24
|
+
engines?: string;
|
|
25
|
+
include_content?: boolean;
|
|
26
|
+
content_chars?: number;
|
|
27
|
+
timeout_ms?: number;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
/** Internal: caller-provided ctx for env lookup. */
|
|
30
|
+
_ctx?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SearchResult {
|
|
34
|
+
title: string;
|
|
35
|
+
url: string;
|
|
36
|
+
snippet: string;
|
|
37
|
+
age: string;
|
|
38
|
+
content: string;
|
|
39
|
+
backend: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SearchAttempt {
|
|
43
|
+
backend: SearchBackend;
|
|
44
|
+
status: SearchAttemptStatus;
|
|
45
|
+
message: string;
|
|
46
|
+
resultCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SearchDiagnostics {
|
|
50
|
+
results: SearchResult[];
|
|
51
|
+
attempts: SearchAttempt[];
|
|
52
|
+
selectedBackend: SearchBackend | "";
|
|
53
|
+
backendOrder: SearchBackend[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BackendConfig {
|
|
57
|
+
searxng: { configured: boolean; baseUrl: string };
|
|
58
|
+
brave: { configured: boolean; apiKey: string };
|
|
59
|
+
firecrawl: { configured: boolean; config: FirecrawlConfig | null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function probeBackends(ctx?: Record<string, unknown>): BackendConfig {
|
|
63
|
+
const cwd = cwdFromContext(ctx ?? {});
|
|
64
|
+
const trusted = includeProjectEnv(ctx ?? {});
|
|
65
|
+
|
|
66
|
+
let searxngBaseUrl: string;
|
|
67
|
+
try {
|
|
68
|
+
const searxngConfig = loadSearxngConfig({}, cwd, trusted);
|
|
69
|
+
searxngBaseUrl = searxngConfig.baseUrl;
|
|
70
|
+
} catch {
|
|
71
|
+
searxngBaseUrl = normalizeSearxngBaseUrl();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
75
|
+
|
|
76
|
+
let fcConfig: FirecrawlConfig | null = null;
|
|
77
|
+
try {
|
|
78
|
+
fcConfig = loadFirecrawlConfig({}, cwd, trusted);
|
|
79
|
+
} catch {
|
|
80
|
+
// Not configured or unsafe to use from this context.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
searxng: { configured: true, baseUrl: searxngBaseUrl },
|
|
85
|
+
brave: { configured: Boolean(braveKey.value), apiKey: braveKey.value ?? "" },
|
|
86
|
+
firecrawl: { configured: fcConfig !== null, config: fcConfig },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function searchSearxng(params: SearchParams): Promise<SearchResult[]> {
|
|
91
|
+
const cfg = loadSearxngConfig(
|
|
92
|
+
{ ...(params.engines ? { engines: params.engines } : {}) } as Record<string, unknown>,
|
|
93
|
+
cwdFromContext(params._ctx ?? {}),
|
|
94
|
+
includeProjectEnv(params._ctx ?? {}),
|
|
95
|
+
);
|
|
96
|
+
const raw = await fetchSearxngResults(
|
|
97
|
+
{ query: params.query, count: params.count ?? 5, engines: params.engines, timeout_ms: params.timeout_ms } as Record<string, unknown>,
|
|
98
|
+
cfg.baseUrl,
|
|
99
|
+
params.signal,
|
|
100
|
+
);
|
|
101
|
+
return (raw.results || []).map((r) => ({
|
|
102
|
+
title: r.title ?? "",
|
|
103
|
+
url: r.url ?? "",
|
|
104
|
+
snippet: r.snippet ?? "",
|
|
105
|
+
age: r.publishedDate ?? "",
|
|
106
|
+
content: "",
|
|
107
|
+
backend: "searxng",
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function searchBrave(params: SearchParams, backends: BackendConfig): Promise<SearchResult[]> {
|
|
112
|
+
if (!backends.brave.configured) throw new Error("Brave not configured (no BRAVE_API_KEY)");
|
|
113
|
+
const raw = await fetchBraveResults(
|
|
114
|
+
params.query,
|
|
115
|
+
params.count ?? 5,
|
|
116
|
+
params.country ?? "US",
|
|
117
|
+
params.freshness ?? "",
|
|
118
|
+
backends.brave.apiKey,
|
|
119
|
+
params.signal,
|
|
120
|
+
);
|
|
121
|
+
const results = raw.map((r) => ({
|
|
122
|
+
title: r.title ?? "",
|
|
123
|
+
url: r.url ?? "",
|
|
124
|
+
snippet: r.snippet ?? "",
|
|
125
|
+
age: r.age ?? "",
|
|
126
|
+
content: "",
|
|
127
|
+
backend: "brave",
|
|
128
|
+
}));
|
|
129
|
+
if (params.include_content && results.length > 0) {
|
|
130
|
+
const maxChars = params.content_chars ?? 5000;
|
|
131
|
+
await Promise.all(
|
|
132
|
+
results.map(async (r) => {
|
|
133
|
+
try {
|
|
134
|
+
const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
|
|
135
|
+
r.content = article.markdown.slice(0, maxChars);
|
|
136
|
+
} catch (e: any) {
|
|
137
|
+
r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function searchFirecrawl(params: SearchParams, backends: BackendConfig): Promise<SearchResult[]> {
|
|
146
|
+
if (!backends.firecrawl.configured || !backends.firecrawl.config) {
|
|
147
|
+
throw new Error("Firecrawl not configured (no FIRECRAWL_API_KEY/FIRECRAWL_API_URL)");
|
|
148
|
+
}
|
|
149
|
+
const body: Record<string, unknown> = {
|
|
150
|
+
query: params.query,
|
|
151
|
+
limit: Math.min(100, Math.max(1, params.count ?? 5)),
|
|
152
|
+
sources: [{ type: "web" }],
|
|
153
|
+
...(params.country ? { country: String(params.country).toUpperCase() } : {}),
|
|
154
|
+
};
|
|
155
|
+
const result = await firecrawlRequest(backends.firecrawl.config, "POST", "/search", body, true, params.signal) as FirecrawlResult;
|
|
156
|
+
const rootData = result.data as Record<string, unknown> | undefined;
|
|
157
|
+
const web = (rootData?.web as unknown[]) || [];
|
|
158
|
+
const news = (rootData?.news as unknown[]) || [];
|
|
159
|
+
const items = [...web, ...news];
|
|
160
|
+
return items.map((r: any) => ({
|
|
161
|
+
title: sanitizeSnippet(r.title ?? r.metadata?.title ?? ""),
|
|
162
|
+
url: r.url ?? r.metadata?.sourceURL ?? "",
|
|
163
|
+
snippet: sanitizeSnippet(r.description ?? r.snippet ?? ""),
|
|
164
|
+
age: "",
|
|
165
|
+
content: r.markdown ? String(r.markdown).slice(0, params.content_chars ?? 5000) : "",
|
|
166
|
+
backend: "firecrawl",
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isPrecisionQuery(params: SearchParams): boolean {
|
|
171
|
+
const query = params.query.trim();
|
|
172
|
+
const lower = query.toLowerCase();
|
|
173
|
+
if (params.include_content) return true;
|
|
174
|
+
if (/\bsite:[^\s]+/i.test(query)) return true;
|
|
175
|
+
if (/"[^"]+"/.test(query)) return true;
|
|
176
|
+
if (/\b(docs?|documentation|api|github|repo|repository|changelog|release|issue|bug|sdk|package)\b/i.test(query)) return true;
|
|
177
|
+
const terms = query.split(/\s+/).filter(Boolean);
|
|
178
|
+
if (terms.length > 0 && terms.length <= 4 && /\b[A-Z][A-Za-z0-9_-]{2,}\b/.test(query)) return true;
|
|
179
|
+
if (/\b[a-z0-9-]+\.(com|dev|io|org|net|app|ai|tv)\b/i.test(lower)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function selectSearchBackendOrder(params: SearchParams): SearchBackend[] {
|
|
184
|
+
if (params.backend && params.backend !== "auto") return [params.backend];
|
|
185
|
+
if (params.engines && !params.include_content) return ["searxng", "brave", "firecrawl"];
|
|
186
|
+
if (isPrecisionQuery(params)) return ["brave", "searxng", "firecrawl"];
|
|
187
|
+
return ["searxng", "brave", "firecrawl"];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sanitizeError(error: unknown): string {
|
|
191
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
192
|
+
return raw
|
|
193
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
194
|
+
.replace(/X-Subscription-Token\s*[:=]\s*[^\s]+/gi, "X-Subscription-Token: [REDACTED]")
|
|
195
|
+
.replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
|
|
196
|
+
.slice(0, 500);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isConfigured(backend: SearchBackend, backends: BackendConfig): boolean {
|
|
200
|
+
if (backend === "searxng") return backends.searxng.configured;
|
|
201
|
+
if (backend === "brave") return backends.brave.configured;
|
|
202
|
+
return backends.firecrawl.configured;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runBackend(backend: SearchBackend, params: SearchParams, backends: BackendConfig): Promise<SearchResult[]> {
|
|
206
|
+
if (backend === "searxng") return searchSearxng(params);
|
|
207
|
+
if (backend === "brave") return searchBrave(params, backends);
|
|
208
|
+
return searchFirecrawl(params, backends);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function searchWithDiagnostics(params: SearchParams): Promise<SearchDiagnostics> {
|
|
212
|
+
const backends = probeBackends(params._ctx);
|
|
213
|
+
const backendOrder = selectSearchBackendOrder(params);
|
|
214
|
+
const attempts: SearchAttempt[] = [];
|
|
215
|
+
|
|
216
|
+
for (const backend of backendOrder) {
|
|
217
|
+
if (!isConfigured(backend, backends)) {
|
|
218
|
+
attempts.push({ backend, status: "skipped", message: `${backend} is not configured`, resultCount: 0 });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const results = await runBackend(backend, params, backends);
|
|
223
|
+
if (results.length > 0) {
|
|
224
|
+
attempts.push({ backend, status: "success", message: `Selected ${backend}`, resultCount: results.length });
|
|
225
|
+
return { results, attempts, selectedBackend: backend, backendOrder };
|
|
226
|
+
}
|
|
227
|
+
attempts.push({ backend, status: "empty", message: `${backend} returned 0 results`, resultCount: 0 });
|
|
228
|
+
} catch (e) {
|
|
229
|
+
attempts.push({ backend, status: "error", message: sanitizeError(e), resultCount: 0 });
|
|
230
|
+
if (params.backend && params.backend !== "auto") break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const diagnostics = attempts.map((a) => `${a.backend}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
|
|
235
|
+
throw new Error(
|
|
236
|
+
`All web search backends failed or returned no results.\n${diagnostics}\n` +
|
|
237
|
+
"Use backend to force a provider or check configuration with web_status.",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Compatibility wrapper returning only results. */
|
|
242
|
+
export async function universalSearch(params: SearchParams): Promise<SearchResult[]> {
|
|
243
|
+
return (await searchWithDiagnostics(params)).results;
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension for web search, page extraction,
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"publishConfig": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"web-search",
|
|
20
20
|
"brave-search",
|
|
21
21
|
"firecrawl",
|
|
22
|
-
"
|
|
22
|
+
"crawl4ai",
|
|
23
|
+
"scraping",
|
|
24
|
+
"crawling"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
27
|
"test": "npx mocha",
|