@bacnh85/pi-web 0.3.0 → 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 +171 -77
- package/index.ts +216 -384
- package/lib/config.ts +8 -8
- package/lib/extract.ts +189 -0
- package/lib/search.ts +244 -0
- package/package.json +1 -1
package/lib/config.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
|
12
12
|
export const OUTPUT_MAX_LINES = 2_000;
|
|
13
13
|
export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
14
14
|
export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
|
|
15
|
-
export const
|
|
15
|
+
export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Environment loading
|
|
@@ -126,8 +126,8 @@ export interface Crawl4aiConfig {
|
|
|
126
126
|
timeoutMs: number;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
export function
|
|
130
|
-
let value = (raw ||
|
|
129
|
+
export function normalizeCrawl4aiApiUrl(raw?: string): string {
|
|
130
|
+
let value = (raw || DEFAULT_CRAWL4AI_API_URL).trim();
|
|
131
131
|
if (!/^https?:\/\//i.test(value)) {
|
|
132
132
|
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
133
133
|
? `http://${value}`
|
|
@@ -137,15 +137,15 @@ export function normalizeCrawl4aiBaseUrl(raw?: string): string {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
|
|
140
|
-
const
|
|
140
|
+
const apiUrlLookup = findEnvValue("CRAWL4AI_API_URL", cwd, includeCwdEnv);
|
|
141
141
|
const apiTokenLookup = findEnvValue("CRAWL4AI_API_TOKEN", cwd, includeCwdEnv);
|
|
142
|
-
const
|
|
142
|
+
const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
|
|
143
143
|
const explicitApiToken = params.crawl4ai_api_token as string | undefined;
|
|
144
|
-
const baseUrl =
|
|
144
|
+
const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
|
|
145
145
|
const apiToken = explicitApiToken || apiTokenLookup.value || "";
|
|
146
|
-
const timeoutValue = (params.timeout_ms as number) || findEnvValue("
|
|
146
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
147
147
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
148
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("
|
|
148
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
149
149
|
return { baseUrl, apiToken, timeoutMs };
|
|
150
150
|
}
|
|
151
151
|
|
package/lib/extract.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Unified content extraction orchestrator.
|
|
2
|
+
// Modes:
|
|
3
|
+
// "static" → JSDOM+Readability (no external API)
|
|
4
|
+
// "dynamic" → Firecrawl Scrape (JS rendering/structured JSON)
|
|
5
|
+
// "full" → Crawl4AI markdown endpoint
|
|
6
|
+
// "auto" → static → dynamic → full
|
|
7
|
+
|
|
8
|
+
import { cwdFromContext, includeProjectEnv } from "./config";
|
|
9
|
+
import { loadFirecrawlConfig, loadCrawl4aiConfig, type FirecrawlConfig, type Crawl4aiConfig } from "./config";
|
|
10
|
+
import { fetchReadableContent } from "./content";
|
|
11
|
+
import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
|
|
12
|
+
import { fetchCrawl4aiMarkdown } from "./crawl4ai";
|
|
13
|
+
import { formatFirecrawlScrape } from "./format";
|
|
14
|
+
|
|
15
|
+
export type ExtractMode = "auto" | "static" | "dynamic" | "full";
|
|
16
|
+
export type ExtractAttemptStatus = "success" | "empty" | "error";
|
|
17
|
+
|
|
18
|
+
export interface ExtractParams {
|
|
19
|
+
url: string;
|
|
20
|
+
mode?: ExtractMode;
|
|
21
|
+
prompt?: string;
|
|
22
|
+
schema?: unknown;
|
|
23
|
+
content_chars?: number;
|
|
24
|
+
timeout_ms?: number;
|
|
25
|
+
wait_for?: number;
|
|
26
|
+
mobile?: boolean;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
/** Internal: caller-provided ctx for env lookup. */
|
|
29
|
+
_ctx?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExtractResult {
|
|
33
|
+
title: string;
|
|
34
|
+
markdown: string;
|
|
35
|
+
backend: string;
|
|
36
|
+
links?: string[];
|
|
37
|
+
structured?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExtractAttempt {
|
|
41
|
+
mode: Exclude<ExtractMode, "auto">;
|
|
42
|
+
backend: string;
|
|
43
|
+
status: ExtractAttemptStatus;
|
|
44
|
+
message: string;
|
|
45
|
+
contentLength: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ExtractDiagnostics {
|
|
49
|
+
result: ExtractResult;
|
|
50
|
+
attempts: ExtractAttempt[];
|
|
51
|
+
selectedMode: Exclude<ExtractMode, "auto">;
|
|
52
|
+
fallbackUsed: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MIN_USEFUL_MARKDOWN_CHARS = 120;
|
|
56
|
+
|
|
57
|
+
function sanitizeError(error: unknown): string {
|
|
58
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
59
|
+
return raw
|
|
60
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
61
|
+
.replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
|
|
62
|
+
.slice(0, 500);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isUseful(result: ExtractResult | null, mode: ExtractMode, explicit: boolean): result is ExtractResult {
|
|
66
|
+
if (!result) return false;
|
|
67
|
+
const length = result.markdown.trim().length;
|
|
68
|
+
if (length === 0) return false;
|
|
69
|
+
return explicit || mode !== "static" || length >= MIN_USEFUL_MARKDOWN_CHARS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function structuredSection(value: unknown): string {
|
|
73
|
+
if (value === undefined) return "";
|
|
74
|
+
return `\n\n## Structured extraction\n\n\`\`\`json\n${JSON.stringify(value, null, 2).slice(0, 10000)}\n\`\`\``;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function extractStatic(params: ExtractParams): Promise<ExtractResult | null> {
|
|
78
|
+
const article = await fetchReadableContent(params.url, params.timeout_ms ?? 15000, params.signal);
|
|
79
|
+
return {
|
|
80
|
+
title: article.title ?? "",
|
|
81
|
+
markdown: article.markdown.slice(0, params.content_chars ?? 20000),
|
|
82
|
+
backend: "static",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findStructuredPayload(result: FirecrawlResult): unknown {
|
|
87
|
+
const data = result.data as Record<string, unknown> | undefined;
|
|
88
|
+
if (!data) return undefined;
|
|
89
|
+
return data.json ?? data.extract ?? data.structuredData ?? data.llm_extraction;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
|
|
93
|
+
let fcConfig: FirecrawlConfig;
|
|
94
|
+
try {
|
|
95
|
+
fcConfig = loadFirecrawlConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const formats: unknown[] = ["markdown"];
|
|
101
|
+
if (params.prompt || params.schema) {
|
|
102
|
+
const jsonEntry: Record<string, unknown> = { type: "json" };
|
|
103
|
+
if (params.prompt) jsonEntry.prompt = params.prompt;
|
|
104
|
+
if (params.schema) jsonEntry.schema = params.schema;
|
|
105
|
+
formats.push(jsonEntry);
|
|
106
|
+
}
|
|
107
|
+
const body: Record<string, unknown> = {
|
|
108
|
+
url: params.url,
|
|
109
|
+
formats,
|
|
110
|
+
onlyMainContent: true,
|
|
111
|
+
...(params.wait_for ? { waitFor: params.wait_for } : {}),
|
|
112
|
+
...(params.mobile ? { mobile: true } : {}),
|
|
113
|
+
};
|
|
114
|
+
const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, true, params.signal) as FirecrawlResult;
|
|
115
|
+
const structured = findStructuredPayload(result);
|
|
116
|
+
const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
|
|
117
|
+
const titleMatch = raw.match(/^# (.+)$/m);
|
|
118
|
+
return {
|
|
119
|
+
title: titleMatch ? titleMatch[1] : "",
|
|
120
|
+
markdown: raw,
|
|
121
|
+
backend: "dynamic",
|
|
122
|
+
structured,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
|
|
127
|
+
let c4aiConfig: Crawl4aiConfig;
|
|
128
|
+
try {
|
|
129
|
+
c4aiConfig = loadCrawl4aiConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw new Error(`Crawl4AI configuration unavailable: ${sanitizeError(e)}`);
|
|
132
|
+
}
|
|
133
|
+
const result = await fetchCrawl4aiMarkdown(c4aiConfig, params.url, "fit", undefined, params.signal);
|
|
134
|
+
return {
|
|
135
|
+
title: "",
|
|
136
|
+
markdown: result.markdown.slice(0, params.content_chars ?? 20000),
|
|
137
|
+
backend: "full",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function modesFor(params: ExtractParams): Array<Exclude<ExtractMode, "auto">> {
|
|
142
|
+
const mode = params.mode ?? "auto";
|
|
143
|
+
if (mode !== "auto") return [mode];
|
|
144
|
+
return ["static", "dynamic", "full"];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runExtractor(mode: Exclude<ExtractMode, "auto">, params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
|
|
148
|
+
if (mode === "static") return extractStatic(params);
|
|
149
|
+
if (mode === "dynamic") return extractDynamic(params, ctx);
|
|
150
|
+
return extractFull(params, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function extractWithDiagnostics(params: ExtractParams): Promise<ExtractDiagnostics> {
|
|
154
|
+
const ctx = params._ctx;
|
|
155
|
+
const explicit = (params.mode ?? "auto") !== "auto";
|
|
156
|
+
const modes = modesFor(params);
|
|
157
|
+
const attempts: ExtractAttempt[] = [];
|
|
158
|
+
|
|
159
|
+
for (const mode of modes) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await runExtractor(mode, params, ctx);
|
|
162
|
+
if (isUseful(result, mode, explicit)) {
|
|
163
|
+
if (!explicit && attempts.length > 0) {
|
|
164
|
+
result.markdown = `[Extraction fell back to ${mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : "Crawl4AI (full browser mode)"}]\n\n${result.markdown}`;
|
|
165
|
+
}
|
|
166
|
+
attempts.push({ mode, backend: result.backend, status: "success", message: `Selected ${mode}`, contentLength: result.markdown.length });
|
|
167
|
+
return { result, attempts, selectedMode: mode, fallbackUsed: attempts.length > 1 };
|
|
168
|
+
}
|
|
169
|
+
const emptyResult = result as ExtractResult | null;
|
|
170
|
+
const length = emptyResult?.markdown.trim().length ?? 0;
|
|
171
|
+
attempts.push({ mode, backend: emptyResult?.backend ?? mode, status: "empty", message: `${mode} returned insufficient content`, contentLength: length });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
attempts.push({ mode, backend: mode, status: "error", message: sanitizeError(e), contentLength: 0 });
|
|
174
|
+
if (explicit) break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const diagnostics = attempts.map((a) => `${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
|
|
179
|
+
const mode = params.mode ?? "auto";
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Extraction failed for ${params.url} with mode="${mode}".\n${diagnostics}\n` +
|
|
182
|
+
"Try a different mode or use web_screenshot for a visual snapshot.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
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
|
+
}
|
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