@bacnh85/pi-web 0.4.1 → 0.4.4
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/.mocharc.yml +5 -0
- package/{index.ts → extensions/index.ts} +17 -37
- package/extensions/lib/brave.ts +45 -0
- package/{lib → extensions/lib}/config.ts +1 -4
- package/{lib → extensions/lib}/crawl4ai.ts +11 -45
- package/{lib → extensions/lib}/extract.ts +1 -5
- package/{lib → extensions/lib}/firecrawl.ts +7 -19
- package/{lib → extensions/lib}/format.ts +2 -71
- package/extensions/lib/retry.ts +29 -0
- package/{lib → extensions/lib}/search.ts +2 -6
- package/extensions/lib/searxng.ts +69 -0
- package/extensions/package.json +3 -0
- package/extensions/test/unit/config.test.ts +309 -0
- package/extensions/test/unit/crawl4ai.test.ts +69 -0
- package/extensions/test/unit/extract.test.ts +134 -0
- package/extensions/test/unit/format.test.ts +194 -0
- package/extensions/test/unit/search.test.ts +147 -0
- package/package.json +11 -10
- package/skills/pi-web/SKILL.md +108 -0
- package/lib/brave.ts +0 -40
- package/lib/retry.ts +0 -142
- package/lib/searxng.ts +0 -63
- /package/{lib → extensions/lib}/content.ts +0 -0
- /package/{types.d.ts → extensions/types.d.ts} +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pi-web format module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { expect } from "chai";
|
|
6
|
+
import {
|
|
7
|
+
sanitizeSnippet,
|
|
8
|
+
truncateText,
|
|
9
|
+
formatFirecrawlScrape,
|
|
10
|
+
formatCrawl4aiResult,
|
|
11
|
+
type SearchResultItem,
|
|
12
|
+
} from "../../lib/format";
|
|
13
|
+
|
|
14
|
+
const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
15
|
+
const OUTPUT_MAX_LINES = 2_000;
|
|
16
|
+
|
|
17
|
+
describe("sanitizeSnippet", () => {
|
|
18
|
+
it("returns empty string for empty input", () => {
|
|
19
|
+
expect(sanitizeSnippet()).to.equal("");
|
|
20
|
+
expect(sanitizeSnippet("")).to.equal("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("strips HTML tags", () => {
|
|
24
|
+
expect(sanitizeSnippet("<b>hello</b> world")).to.equal("hello world");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("decodes HTML entities", () => {
|
|
28
|
+
expect(sanitizeSnippet("foo & bar")).to.equal("foo & bar");
|
|
29
|
+
expect(sanitizeSnippet("<tag>")).to.equal("<tag>");
|
|
30
|
+
expect(sanitizeSnippet(""quoted"")).to.equal('"quoted"');
|
|
31
|
+
expect(sanitizeSnippet("hello world")).to.equal("hello world");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("decodes numeric HTML entities", () => {
|
|
35
|
+
expect(sanitizeSnippet("A")).to.equal("A");
|
|
36
|
+
expect(sanitizeSnippet("A")).to.equal("A");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("collapses whitespace", () => {
|
|
40
|
+
expect(sanitizeSnippet("hello world")).to.equal("hello world");
|
|
41
|
+
expect(sanitizeSnippet(" hello ")).to.equal("hello");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("truncateText", () => {
|
|
46
|
+
it("returns short text as-is", () => {
|
|
47
|
+
const text = "hello world";
|
|
48
|
+
expect(truncateText(text)).to.equal(text);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("truncates by line count when exceeding OUTPUT_MAX_LINES", () => {
|
|
52
|
+
const lines = Array.from({ length: OUTPUT_MAX_LINES + 10 }, (_, i) => `line ${i}`);
|
|
53
|
+
const text = lines.join("\n");
|
|
54
|
+
const result = truncateText(text);
|
|
55
|
+
expect(result).to.include("[Web output truncated to");
|
|
56
|
+
expect(result.split("\n").length).to.be.lessThan(OUTPUT_MAX_LINES + 15);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("truncates by byte count when exceeding OUTPUT_MAX_BYTES", () => {
|
|
60
|
+
const text = "x".repeat(OUTPUT_MAX_BYTES + 50000);
|
|
61
|
+
const result = truncateText(text);
|
|
62
|
+
expect(result).to.include("[Web output truncated to");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles empty string", () => {
|
|
66
|
+
expect(truncateText("")).to.equal("");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("formatFirecrawlScrape", () => {
|
|
71
|
+
it("formats scrape data with metadata", () => {
|
|
72
|
+
const data = {
|
|
73
|
+
data: {
|
|
74
|
+
metadata: {
|
|
75
|
+
title: "Test Page",
|
|
76
|
+
sourceURL: "https://example.com",
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
},
|
|
79
|
+
markdown: "# Hello\n\nThis is content.",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const output = formatFirecrawlScrape(data);
|
|
83
|
+
expect(output).to.include("# Test Page");
|
|
84
|
+
expect(output).to.include("Source: https://example.com");
|
|
85
|
+
expect(output).to.include("Status: 200");
|
|
86
|
+
expect(output).to.include("# Hello");
|
|
87
|
+
expect(output).to.include("This is content.");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("resolves data at top level when no nested data", () => {
|
|
91
|
+
const data = {
|
|
92
|
+
metadata: { title: "Direct" },
|
|
93
|
+
markdown: "Content",
|
|
94
|
+
};
|
|
95
|
+
const output = formatFirecrawlScrape(data);
|
|
96
|
+
expect(output).to.include("# Direct");
|
|
97
|
+
expect(output).to.include("Content");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("includes warning when present", () => {
|
|
101
|
+
const data = { data: { markdown: "x" }, warning: "Rate limited" };
|
|
102
|
+
const output = formatFirecrawlScrape(data);
|
|
103
|
+
expect(output).to.include("Warning: Rate limited");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("includes links when present", () => {
|
|
107
|
+
const data = {
|
|
108
|
+
data: {
|
|
109
|
+
markdown: "body",
|
|
110
|
+
links: ["https://a.com", "https://b.com"],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const output = formatFirecrawlScrape(data);
|
|
114
|
+
expect(output).to.include("## Links");
|
|
115
|
+
expect(output).to.include("- https://a.com");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns JSON stringify fallback for empty data", () => {
|
|
119
|
+
const data = { foo: "bar" };
|
|
120
|
+
const output = formatFirecrawlScrape(data);
|
|
121
|
+
expect(output).to.include("foo");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("formatCrawl4aiResult", () => {
|
|
126
|
+
it("formats a single successful crawl result with markdown", () => {
|
|
127
|
+
const data = {
|
|
128
|
+
url: "https://example.com",
|
|
129
|
+
success: true,
|
|
130
|
+
status_code: 200,
|
|
131
|
+
markdown: { fit_markdown: "# Hello\n\nWorld content." },
|
|
132
|
+
};
|
|
133
|
+
const output = formatCrawl4aiResult(data);
|
|
134
|
+
expect(output).to.include("URL: https://example.com");
|
|
135
|
+
expect(output).to.include("Status: 200");
|
|
136
|
+
expect(output).to.include("# Hello");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shows error for failed crawl", () => {
|
|
140
|
+
const data = {
|
|
141
|
+
url: "https://example.com/404",
|
|
142
|
+
success: false,
|
|
143
|
+
error_message: "Not Found",
|
|
144
|
+
};
|
|
145
|
+
const output = formatCrawl4aiResult(data);
|
|
146
|
+
expect(output).to.include("Error: Not Found");
|
|
147
|
+
expect(output).to.include("URL: https://example.com/404");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("formats multiple results from wrapped response", () => {
|
|
151
|
+
const data = {
|
|
152
|
+
results: [
|
|
153
|
+
{ url: "https://a.com", success: true, markdown: { raw_markdown: "Page A" } },
|
|
154
|
+
{ url: "https://b.com", success: true, markdown: { raw_markdown: "Page B" } },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
const output = formatCrawl4aiResult(data);
|
|
158
|
+
expect(output).to.include("=== Result 1 ===");
|
|
159
|
+
expect(output).to.include("=== Result 2 ===");
|
|
160
|
+
expect(output).to.include("Page A");
|
|
161
|
+
expect(output).to.include("Page B");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("falls back to raw_markdown when fit_markdown is absent", () => {
|
|
165
|
+
const data = {
|
|
166
|
+
url: "https://example.com",
|
|
167
|
+
success: true,
|
|
168
|
+
markdown: { raw_markdown: "Raw content only" },
|
|
169
|
+
};
|
|
170
|
+
const output = formatCrawl4aiResult(data);
|
|
171
|
+
expect(output).to.include("Raw content only");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("includes links when present", () => {
|
|
175
|
+
const data = {
|
|
176
|
+
url: "https://example.com",
|
|
177
|
+
success: true,
|
|
178
|
+
markdown: { fit_markdown: "body" },
|
|
179
|
+
links: {
|
|
180
|
+
internal: [{ href: "https://example.com/about" }],
|
|
181
|
+
external: [{ href: "https://other.com" }],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const output = formatCrawl4aiResult(data);
|
|
185
|
+
expect(output).to.include("Links");
|
|
186
|
+
expect(output).to.include("https://example.com/about");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns fallback for empty data", () => {
|
|
190
|
+
expect(formatCrawl4aiResult({})).to.equal("(No data)");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
searchWithDiagnostics,
|
|
5
|
+
selectSearchBackendOrder,
|
|
6
|
+
type SearchParams,
|
|
7
|
+
type SearchResult,
|
|
8
|
+
} from "../../lib/search";
|
|
9
|
+
|
|
10
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
11
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function htmlResponse(body: string, status = 200): Response {
|
|
21
|
+
return new Response(body, {
|
|
22
|
+
status,
|
|
23
|
+
headers: { "content-type": "text/html" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function installMockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>): string[] {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
30
|
+
const url = String(input);
|
|
31
|
+
calls.push(url);
|
|
32
|
+
return handler(url, init);
|
|
33
|
+
}) as typeof fetch;
|
|
34
|
+
return calls;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function restoreEnv(): void {
|
|
38
|
+
process.env = { ...ORIGINAL_ENV };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("SearchParams and SearchResult types", () => {
|
|
42
|
+
it("accept expected fields", () => {
|
|
43
|
+
const params: SearchParams = { query: "test", backend: "auto", signal: new AbortController().signal };
|
|
44
|
+
const result: SearchResult = { title: "T", url: "https://example.com", snippet: "S", age: "", content: "", backend: "brave" };
|
|
45
|
+
expect(params.backend).to.equal("auto");
|
|
46
|
+
expect(result.backend).to.equal("brave");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("selectSearchBackendOrder", () => {
|
|
51
|
+
it("selects Brave first for include_content", () => {
|
|
52
|
+
expect(selectSearchBackendOrder({ query: "homelab ansible", include_content: true })).to.deep.equal(["brave", "searxng", "firecrawl"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("selects Brave first for site: and precision queries", () => {
|
|
56
|
+
expect(selectSearchBackendOrder({ query: "site:docs.ansible.com podman quadlet" })).to.deep.equal(["brave", "searxng", "firecrawl"]);
|
|
57
|
+
expect(selectSearchBackendOrder({ query: '"exact phrase" release notes' })).to.deep.equal(["brave", "searxng", "firecrawl"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps SearXNG first for broad discovery and explicit engines", () => {
|
|
61
|
+
expect(selectSearchBackendOrder({ query: "homelab ansible ideas" })).to.deep.equal(["searxng", "brave", "firecrawl"]);
|
|
62
|
+
expect(selectSearchBackendOrder({ query: "docs api", engines: "google,github" })).to.deep.equal(["searxng", "brave", "firecrawl"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("honors explicit backend", () => {
|
|
66
|
+
expect(selectSearchBackendOrder({ query: "test", backend: "firecrawl" })).to.deep.equal(["firecrawl"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("searchWithDiagnostics", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
restoreEnv();
|
|
73
|
+
process.env.BRAVE_API_KEY = "test-brave-key";
|
|
74
|
+
process.env.SEARXNG_BASE_URL = "http://searxng.test";
|
|
75
|
+
process.env.FIRECRAWL_API_URL = "http://firecrawl.test/v2";
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
80
|
+
restoreEnv();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses Brave first for include_content and fetches inline content", async () => {
|
|
84
|
+
const calls = installMockFetch((url) => {
|
|
85
|
+
if (url.startsWith("https://api.search.brave.com/")) {
|
|
86
|
+
return jsonResponse({ web: { results: [{ title: "Brave", url: "https://example.com/page", description: "Snippet" }] } });
|
|
87
|
+
}
|
|
88
|
+
if (url === "https://example.com/page") {
|
|
89
|
+
return htmlResponse("<html><head><title>Page</title></head><body><main><h1>Page</h1><p>This is long enough readable content for the inline content fetch test.</p></main></body></html>");
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await searchWithDiagnostics({ query: "homelab ansible", include_content: true });
|
|
95
|
+
expect(result.selectedBackend).to.equal("brave");
|
|
96
|
+
expect(result.backendOrder[0]).to.equal("brave");
|
|
97
|
+
expect(result.results[0].content).to.include("readable content");
|
|
98
|
+
expect(calls[0]).to.include("api.search.brave.com");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("uses SearXNG first for broad discovery", async () => {
|
|
102
|
+
const calls = installMockFetch((url) => {
|
|
103
|
+
if (url.startsWith("http://searxng.test/search")) {
|
|
104
|
+
return jsonResponse({ results: [{ title: "SearXNG", url: "https://example.com", content: "Snippet" }] });
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await searchWithDiagnostics({ query: "homelab ansible ideas" });
|
|
110
|
+
expect(result.selectedBackend).to.equal("searxng");
|
|
111
|
+
expect(result.attempts).to.deep.include({ backend: "searxng", status: "success", message: "Selected searxng", resultCount: 1 });
|
|
112
|
+
expect(calls[0]).to.include("searxng.test");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("captures backend errors and falls through to Firecrawl last", async () => {
|
|
116
|
+
installMockFetch((url) => {
|
|
117
|
+
if (url.startsWith("http://searxng.test/search")) return jsonResponse({ results: [] });
|
|
118
|
+
if (url.startsWith("https://api.search.brave.com/")) return jsonResponse({ error: "rate limited" }, 429);
|
|
119
|
+
if (url.startsWith("http://firecrawl.test/v2/search")) {
|
|
120
|
+
return jsonResponse({ data: { web: [{ title: "Fire", url: "https://fire.example", description: "Fallback" }] } });
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await searchWithDiagnostics({ query: "homelab ansible ideas" });
|
|
126
|
+
expect(result.selectedBackend).to.equal("firecrawl");
|
|
127
|
+
expect(result.attempts.map((a) => a.backend)).to.deep.equal(["searxng", "brave", "firecrawl"]);
|
|
128
|
+
expect(result.attempts[1].status).to.equal("error");
|
|
129
|
+
expect(result.attempts[1].message).not.to.include("test-brave-key");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("explicit backend tries only that backend", async () => {
|
|
133
|
+
const calls = installMockFetch((url) => {
|
|
134
|
+
if (url.startsWith("http://searxng.test/search")) return jsonResponse({ results: [] });
|
|
135
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await searchWithDiagnostics({ query: "nothing", backend: "searxng" });
|
|
140
|
+
expect.fail("expected search to fail");
|
|
141
|
+
} catch (e: any) {
|
|
142
|
+
expect(e.message).to.include("searxng: empty");
|
|
143
|
+
expect(calls).to.have.length(1);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
});
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-web",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
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": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://github.com/bacnh85/
|
|
10
|
+
"homepage": "https://github.com/bacnh85/pi-extensions#readme",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/bacnh85/
|
|
14
|
-
"directory": "
|
|
13
|
+
"url": "git+https://github.com/bacnh85/pi-extensions.git",
|
|
14
|
+
"directory": "pi-web"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"pi-package",
|
|
@@ -24,18 +24,19 @@
|
|
|
24
24
|
"crawling"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "
|
|
28
|
-
"test:unit": "npx mocha --spec 'test/unit/**/*.test.ts'"
|
|
27
|
+
"test": "cd extensions && mocha"
|
|
29
28
|
},
|
|
30
29
|
"files": [
|
|
31
30
|
"README.md",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"lib/"
|
|
31
|
+
"extensions/",
|
|
32
|
+
"skills/"
|
|
35
33
|
],
|
|
36
34
|
"pi": {
|
|
37
35
|
"extensions": [
|
|
38
|
-
"./index.ts"
|
|
36
|
+
"./extensions/index.ts"
|
|
37
|
+
],
|
|
38
|
+
"skills": [
|
|
39
|
+
"./skills"
|
|
39
40
|
]
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-web
|
|
3
|
+
description: Web search, content extraction, site crawling, and page capture via the pi-web extension. Use when the user needs current web search results, documentation lookup, factual research, source discovery, URL-to-markdown extraction, JSON extraction from websites, site URL discovery, site crawling, or page screenshots/PDFs. Use when the user mentions searching the web, finding docs, looking something up, researching, scraping/extracting content from a URL, or capturing a page.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-web — Unified Web Tools
|
|
7
|
+
|
|
8
|
+
Use the **7 unified tools** from the `pi-web` extension for all web-related tasks. These tools automatically select the best backend from SearXNG, Brave Search, Firecrawl, and Crawl4AI — you don't need to know which backend to use. Search selection is adaptive: broad discovery prefers self-hosted SearXNG, while precision-sensitive queries and inline content prefer Brave.
|
|
9
|
+
|
|
10
|
+
## Quick Reference
|
|
11
|
+
|
|
12
|
+
| Tool | Purpose | Auto-selection |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `web_search` | Search the web for sources, docs, facts | SearXNG → Brave → Firecrawl |
|
|
15
|
+
| `web_extract` | Extract readable content from a URL | Static (JSDOM) → Dynamic (Firecrawl) → Full (Crawl4AI) |
|
|
16
|
+
| `web_map` | Discover URLs from a site | Firecrawl Map (only option) |
|
|
17
|
+
| `web_crawl` | Crawl multiple pages from a site | Light (Firecrawl) or Full (Crawl4AI) |
|
|
18
|
+
| `web_screenshot` | Capture page screenshot as PNG | Crawl4AI (only option) |
|
|
19
|
+
| `web_pdf` | Generate page PDF | Crawl4AI (only option) |
|
|
20
|
+
| `web_status` | Check provider configuration and health | — |
|
|
21
|
+
|
|
22
|
+
## Decision Tree
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
What do you need?
|
|
26
|
+
│
|
|
27
|
+
├── Search results (URLs, snippets, docs lookup)
|
|
28
|
+
│ → web_search
|
|
29
|
+
│ ├─ default: adaptive auto-selects SearXNG or Brave, then Firecrawl
|
|
30
|
+
│ ├─ precision/inline content: auto prefers Brave, or explicit backend=brave
|
|
31
|
+
│ └─ SearXNG engine tuning: engines=google,github
|
|
32
|
+
│
|
|
33
|
+
├── Content from a known URL (markdown, structured data)
|
|
34
|
+
│ → web_extract
|
|
35
|
+
│ ├─ static page (blog, docs): mode=static (fastest, no API key)
|
|
36
|
+
│ ├─ dynamic page (JS-rendered): mode=dynamic
|
|
37
|
+
│ ├─ JS-heavy SPA: mode=full
|
|
38
|
+
│ └─ auto (default): tries static > dynamic > full
|
|
39
|
+
│
|
|
40
|
+
├── Site URL discovery (find pages on a site)
|
|
41
|
+
│ → web_map
|
|
42
|
+
│ └─ sitemap=only for sitemap-only discovery
|
|
43
|
+
│
|
|
44
|
+
├── Crawl multiple pages from a site
|
|
45
|
+
│ → web_crawl
|
|
46
|
+
│ ├─ docs/docs section: mode=light (default, Firecrawl)
|
|
47
|
+
│ └─ rendered data with media/links: mode=full (Crawl4AI)
|
|
48
|
+
│
|
|
49
|
+
├── Visual snapshot of a page
|
|
50
|
+
│ → web_screenshot
|
|
51
|
+
│
|
|
52
|
+
├── Printable/archivable PDF of a page
|
|
53
|
+
│ → web_pdf
|
|
54
|
+
│
|
|
55
|
+
└── Check what web tools are configured
|
|
56
|
+
→ web_status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Auto-selection Details
|
|
60
|
+
|
|
61
|
+
### `web_search` adaptive backend order
|
|
62
|
+
|
|
63
|
+
1. **SearXNG** (self-hosted, free) — first for broad/general discovery.
|
|
64
|
+
- Use `engines` parameter to tune: `engines: "google,github"` for technical queries.
|
|
65
|
+
- If auto-selection returns poor results, try `backend: "brave"` for a different search index.
|
|
66
|
+
2. **Brave Search** (hosted, requires API key) — first for precision-sensitive queries and inline content.
|
|
67
|
+
- Auto mode prefers Brave for `include_content`, `site:` searches, quoted phrases, docs/API/source lookups, short proper-name queries, and domain-specific/ambiguous queries.
|
|
68
|
+
- Supports `include_content` for inline page content.
|
|
69
|
+
- Handles page-content fetch failures gracefully by keeping search results and adding per-result notes.
|
|
70
|
+
3. **Firecrawl Search** — last resort.
|
|
71
|
+
- ⚠️ **Poor semantic accuracy** on domain-specific/ambiguous queries. E.g., "Riven" returns League of Legends champion build guide instead of the media-automation tool. Prefer SearXNG or Brave for precision.
|
|
72
|
+
- Acceptable for general technical queries.
|
|
73
|
+
|
|
74
|
+
### `web_extract` mode order
|
|
75
|
+
|
|
76
|
+
1. **static** (JSDOM+Readability) — no API key needed, works on simple static sites, blogs, and doc pages. Fastest option.
|
|
77
|
+
2. **dynamic** (Firecrawl Scrape) — handles JS-rendered pages and dynamic content.
|
|
78
|
+
- ❌ Fails on bot-protected sites (Ansible docs, many CDN-backed doc sites). Falls through Crawl4AI in `auto` mode.
|
|
79
|
+
- ✅ Supported: prompt-based JSON extraction, schema-based structured extraction.
|
|
80
|
+
3. **full** (Crawl4AI headless browser) — handles all content types. **Resource-intensive** (launches a full headless browser). Use only when static and dynamic modes fail, or when explicitly needed.
|
|
81
|
+
|
|
82
|
+
## Fallback Strategy
|
|
83
|
+
|
|
84
|
+
If one tool fails, try the next option in the chain:
|
|
85
|
+
|
|
86
|
+
- **Search issues**: `web_search` auto-fallbacks and reports backend diagnostics. If all backends fail, configure at least one via env vars (check `web_status`).
|
|
87
|
+
- **Extraction issues**: `web_extract` auto-fallbacks in `auto` mode. If all modes fail:
|
|
88
|
+
1. Try `web_screenshot` for a visual snapshot — may work when extraction is blocked.
|
|
89
|
+
2. The page may require interactive login, CAPTCHA, or be a non-HTML resource.
|
|
90
|
+
- **Tool not found**: Ensure `pi-web` extension is installed (`pi install ./extensions/pi-web`).
|
|
91
|
+
|
|
92
|
+
## Cross-tool Decision Guide
|
|
93
|
+
|
|
94
|
+
| If you need... | Use this | Instead of... |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| A few specific pages from a site | `web_map` + `web_extract` on each URL | `web_crawl` (heavier than needed) |
|
|
97
|
+
| Content from a JS-heavy page that fails in `auto` mode | `web_extract` with `mode: "full"` | Retrying `auto` mode repeatedly |
|
|
98
|
+
| A visual of a bot-protected page | `web_screenshot` | Retrying `web_extract` with all modes |
|
|
99
|
+
| Content alongside search results | `web_search` with `include_content: true` (auto prefers Brave) or `backend: "brave"` | Search snippets alone |
|
|
100
|
+
| Printable/archivable page | `web_pdf` | Taking a screenshot and converting |
|
|
101
|
+
| All URLs on a docs site | `web_map` with `sitemap: "only"` | Crawling the entire site |
|
|
102
|
+
|
|
103
|
+
## Important Notes
|
|
104
|
+
|
|
105
|
+
- **Always cite source URLs** when using web content in answers.
|
|
106
|
+
- `web_status` shows which backends are configured without printing secrets. For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth — check the `ready` field to see if Firecrawl is actually usable.
|
|
107
|
+
- The `backend` and `mode` parameters give explicit control when auto-selection is not desired.
|
|
108
|
+
- Backend-specific config (API keys, URLs) comes from environment variables, not tool parameters. Use `web_status` to verify configuration.
|
package/lib/brave.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// Brave Search API client.
|
|
2
|
-
|
|
3
|
-
import { sanitizeSnippet, type SearchResultItem } from "./format";
|
|
4
|
-
import { withRetry } from "./retry";
|
|
5
|
-
|
|
6
|
-
export interface BraveResult extends SearchResultItem {
|
|
7
|
-
age: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function fetchBraveResults(
|
|
11
|
-
query: string,
|
|
12
|
-
count: number,
|
|
13
|
-
country: string,
|
|
14
|
-
freshness: string,
|
|
15
|
-
apiKey: string,
|
|
16
|
-
signal?: AbortSignal,
|
|
17
|
-
): Promise<BraveResult[]> {
|
|
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
|
-
}, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
|
|
40
|
-
}
|
package/lib/retry.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
// Retry logic with exponential backoff for transient network failures.
|
|
2
|
-
// Ported from pi-munin/lib/retry.ts.
|
|
3
|
-
|
|
4
|
-
export interface RetryOptions {
|
|
5
|
-
maxRetries?: number;
|
|
6
|
-
initialDelay?: number;
|
|
7
|
-
maxDelay?: number;
|
|
8
|
-
multiplier?: number;
|
|
9
|
-
retryableErrors?: string[];
|
|
10
|
-
onRetry?: (attempt: number, max: number, delay: number, error: Error) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function isRetryableError(error: Error): boolean {
|
|
14
|
-
const errorMessage = (error.message || "").toLowerCase();
|
|
15
|
-
const retryablePatterns = [
|
|
16
|
-
"econnrefused",
|
|
17
|
-
"enotfound",
|
|
18
|
-
"etimedout",
|
|
19
|
-
"econnreset",
|
|
20
|
-
"econnaborted",
|
|
21
|
-
"enetunreach",
|
|
22
|
-
"etimeout",
|
|
23
|
-
"timeout",
|
|
24
|
-
"network",
|
|
25
|
-
"socket hang up",
|
|
26
|
-
"socket",
|
|
27
|
-
"connection",
|
|
28
|
-
"fetch",
|
|
29
|
-
"request timeout",
|
|
30
|
-
];
|
|
31
|
-
return retryablePatterns.some((pattern) => errorMessage.includes(pattern));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function sleep(ms: number): Promise<void> {
|
|
35
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function calculateDelay(
|
|
39
|
-
attempt: number,
|
|
40
|
-
initialDelay: number,
|
|
41
|
-
maxDelay: number,
|
|
42
|
-
multiplier: number,
|
|
43
|
-
): number {
|
|
44
|
-
const exponentialDelay = initialDelay * Math.pow(multiplier, attempt);
|
|
45
|
-
const jitter = Math.random() * 0.1 * exponentialDelay;
|
|
46
|
-
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Retry a function with exponential backoff.
|
|
51
|
-
* Never retries auth, not_found, or validation errors.
|
|
52
|
-
*/
|
|
53
|
-
export async function withRetry<T>(
|
|
54
|
-
fn: () => Promise<T>,
|
|
55
|
-
options: RetryOptions = {},
|
|
56
|
-
): Promise<T> {
|
|
57
|
-
const {
|
|
58
|
-
maxRetries = 3,
|
|
59
|
-
initialDelay = 1000,
|
|
60
|
-
maxDelay = 10000,
|
|
61
|
-
multiplier = 2,
|
|
62
|
-
retryableErrors = [],
|
|
63
|
-
onRetry,
|
|
64
|
-
} = options;
|
|
65
|
-
|
|
66
|
-
// Errors we never retry (non-transient)
|
|
67
|
-
const nonRetryablePatterns = [
|
|
68
|
-
"unauthorized",
|
|
69
|
-
"invalid api key",
|
|
70
|
-
"not found",
|
|
71
|
-
"validation",
|
|
72
|
-
"required",
|
|
73
|
-
"refusing to send",
|
|
74
|
-
"refusing to send a configured",
|
|
75
|
-
"must use http or https",
|
|
76
|
-
"could not extract readable content",
|
|
77
|
-
"brave_api_key is required",
|
|
78
|
-
"firecrawl_api_key is required",
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
let lastError: Error;
|
|
82
|
-
|
|
83
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
84
|
-
try {
|
|
85
|
-
return await fn();
|
|
86
|
-
} catch (error) {
|
|
87
|
-
lastError = error as Error;
|
|
88
|
-
|
|
89
|
-
if (attempt === maxRetries) throw error;
|
|
90
|
-
|
|
91
|
-
// Never retry non-transient errors
|
|
92
|
-
const msg = lastError.message.toLowerCase();
|
|
93
|
-
if (nonRetryablePatterns.some((p) => msg.includes(p))) throw error;
|
|
94
|
-
|
|
95
|
-
// Check if error is retryable
|
|
96
|
-
const shouldRetry =
|
|
97
|
-
retryableErrors.length > 0
|
|
98
|
-
? retryableErrors.some((pattern) => msg.includes(pattern.toLowerCase()))
|
|
99
|
-
: isRetryableError(lastError);
|
|
100
|
-
|
|
101
|
-
if (!shouldRetry) throw error;
|
|
102
|
-
|
|
103
|
-
const delay = calculateDelay(attempt, initialDelay, maxDelay, multiplier);
|
|
104
|
-
|
|
105
|
-
if (onRetry) {
|
|
106
|
-
onRetry(attempt + 1, maxRetries + 1, delay, lastError);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
await sleep(delay);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
throw lastError!;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Combine a timeout signal with an optional external signal.
|
|
118
|
-
*/
|
|
119
|
-
export function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
120
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
121
|
-
if (!signal) return timeoutSignal;
|
|
122
|
-
return AbortSignal.any([signal, timeoutSignal]);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Sleep that is abortable via an AbortSignal.
|
|
127
|
-
*/
|
|
128
|
-
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
129
|
-
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Operation aborted."));
|
|
130
|
-
return new Promise((resolve, reject) => {
|
|
131
|
-
const timer = setTimeout(cleanupResolve, ms);
|
|
132
|
-
function cleanupResolve() {
|
|
133
|
-
signal?.removeEventListener("abort", cleanupReject);
|
|
134
|
-
resolve();
|
|
135
|
-
}
|
|
136
|
-
function cleanupReject() {
|
|
137
|
-
clearTimeout(timer);
|
|
138
|
-
reject(signal?.reason ?? new Error("Operation aborted."));
|
|
139
|
-
}
|
|
140
|
-
signal?.addEventListener("abort", cleanupReject, { once: true });
|
|
141
|
-
});
|
|
142
|
-
}
|