@rol1n/web-reach 0.0.0-stage → 0.1.0
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 +36 -2
- package/extensions/web-reach/README.md +37 -0
- package/extensions/web-reach/html.ts +83 -0
- package/extensions/web-reach/index.ts +82 -0
- package/extensions/web-reach/page.ts +132 -0
- package/extensions/web-reach/pdf.ts +42 -0
- package/extensions/web-reach/reader.ts +42 -0
- package/extensions/web-reach/rsc.ts +161 -0
- package/extensions/web-reach/search.ts +85 -0
- package/extensions/web-reach/transport.ts +35 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# web-reach
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web search and page extraction tools for Pi. Registers `web_search` through Exa's public MCP endpoint and `web_fetch` for readable Markdown or raw responses.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/web-reach
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`. Avoid enabling another extension that registers the same tool names unless overriding them is intentional.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{"query":"Pi coding agent package resource filtering"}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{"url":"https://example.com/docs","mode":"readable"}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The first object is for `web_search`; the second is for `web_fetch`. Fetch mode defaults to `readable` and also accepts `raw`. Extraction includes HTML-to-Markdown, JSON formatting, PDF handling, and a Jina Reader fallback for pages that need it. Returned text is limited to 35,000 characters.
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
| Environment variable | Default |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `SRP_WEB_SEARCH_TIMEOUT_MS` | 60000 |
|
|
30
|
+
| `SRP_WEB_FETCH_TIMEOUT_MS` | 20000 |
|
|
31
|
+
| `SRP_WEB_FETCH_MAX_BYTES` | 4194304 (4 MiB) |
|
|
32
|
+
|
|
33
|
+
No separate search API key is configured; availability and rate limits depend on the public Exa and Jina services. PDF extraction optionally imports `unpdf` if it is available. Without that package the implementation falls back to a basic text extraction path, with lower PDF text quality.
|
|
34
|
+
|
|
35
|
+
## Source layout
|
|
36
|
+
|
|
37
|
+
`index.ts` registers the tools and re-exports `searchWeb` and `fetchUrl`. `search.ts` handles Exa MCP requests and response parsing; `transport.ts` shares timeout, cancellation, environment, and truncation helpers. `page.ts` coordinates page fetching and extraction, using `rsc.ts` for Next.js data, `html.ts` for semantic Markdown, `pdf.ts` for optional PDF extraction, and `reader.ts` for the Jina Reader fallback.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# web-reach
|
|
2
|
+
|
|
3
|
+
Web search and page extraction tools for Pi. Registers `web_search` through Exa's public MCP endpoint and `web_fetch` for readable Markdown or raw responses.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/web-reach
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`. Avoid enabling another extension that registers the same tool names unless overriding them is intentional.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{"query":"Pi coding agent package resource filtering"}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{"url":"https://example.com/docs","mode":"readable"}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The first object is for `web_search`; the second is for `web_fetch`. Fetch mode defaults to `readable` and also accepts `raw`. Extraction includes HTML-to-Markdown, JSON formatting, PDF handling, and a Jina Reader fallback for pages that need it. Returned text is limited to 35,000 characters.
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
| Environment variable | Default |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `SRP_WEB_SEARCH_TIMEOUT_MS` | 60000 |
|
|
30
|
+
| `SRP_WEB_FETCH_TIMEOUT_MS` | 20000 |
|
|
31
|
+
| `SRP_WEB_FETCH_MAX_BYTES` | 4194304 (4 MiB) |
|
|
32
|
+
|
|
33
|
+
No separate search API key is configured; availability and rate limits depend on the public Exa and Jina services. PDF extraction optionally imports `unpdf` if it is available. Without that package the implementation falls back to a basic text extraction path, with lower PDF text quality.
|
|
34
|
+
|
|
35
|
+
## Source layout
|
|
36
|
+
|
|
37
|
+
`index.ts` registers the tools and re-exports `searchWeb` and `fetchUrl`. `search.ts` handles Exa MCP requests and response parsing; `transport.ts` shares timeout, cancellation, environment, and truncation helpers. `page.ts` coordinates page fetching and extraction, using `rsc.ts` for Next.js data, `html.ts` for semantic Markdown, `pdf.ts` for optional PDF extraction, and `reader.ts` for the Jina Reader fallback.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function extractTitleFromHtml(html: string): string {
|
|
2
|
+
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
3
|
+
return match?.[1]?.trim() ?? "";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isLikelyJSRendered(html: string): boolean {
|
|
7
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
8
|
+
if (!bodyMatch) return false;
|
|
9
|
+
const textContent = bodyMatch[1]
|
|
10
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
11
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
12
|
+
.replace(/<[^>]+>/g, "")
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
const scriptCount = (html.match(/<script/gi) || []).length;
|
|
16
|
+
return textContent.length < 300 && scriptCount >= 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function htmlToMarkdown(html: string): string {
|
|
20
|
+
let s = html.replace(/<(script|style|noscript|svg|canvas|form|nav|footer|header|aside)[\s\S]*?<\/\1>/gi, " ");
|
|
21
|
+
s = s.replace(/<!--[\s\S]*?-->/g, " ");
|
|
22
|
+
|
|
23
|
+
// Headings
|
|
24
|
+
s = s.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n\n# $1\n\n");
|
|
25
|
+
s = s.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n\n## $1\n\n");
|
|
26
|
+
s = s.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n\n### $1\n\n");
|
|
27
|
+
s = s.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "\n\n#### $1\n\n");
|
|
28
|
+
s = s.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, "\n\n##### $1\n\n");
|
|
29
|
+
s = s.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, "\n\n###### $1\n\n");
|
|
30
|
+
|
|
31
|
+
// Code blocks and inline code
|
|
32
|
+
s = s.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, "\n```\n$1\n```\n");
|
|
33
|
+
s = s.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
|
|
34
|
+
s = s.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, " `$1` ");
|
|
35
|
+
|
|
36
|
+
// Blockquotes and emphasis
|
|
37
|
+
s = s.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, "\n> $1\n");
|
|
38
|
+
s = s.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**");
|
|
39
|
+
s = s.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*");
|
|
40
|
+
|
|
41
|
+
// Links
|
|
42
|
+
s = s.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => {
|
|
43
|
+
const t = text.replace(/<[^>]*>/g, "").trim();
|
|
44
|
+
if (!t) return "";
|
|
45
|
+
if (href.startsWith("#") || href.startsWith("javascript:")) return t;
|
|
46
|
+
return `[${t}](${href})`;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Lists
|
|
50
|
+
s = s.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "\n- $1");
|
|
51
|
+
s = s.replace(/<\/(ul|ol)>/gi, "\n\n");
|
|
52
|
+
|
|
53
|
+
// Tables
|
|
54
|
+
s = s.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, "\n$1 |");
|
|
55
|
+
s = s.replace(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi, " | $1");
|
|
56
|
+
s = s.replace(/<\/(table|thead|tbody)>/gi, "\n\n");
|
|
57
|
+
|
|
58
|
+
// Paragraphs and line breaks
|
|
59
|
+
s = s.replace(/<\/(p|div|section|article)>/gi, "\n\n");
|
|
60
|
+
s = s.replace(/<br\s*\/?>/gi, "\n");
|
|
61
|
+
|
|
62
|
+
// Strip remaining tags
|
|
63
|
+
s = s.replace(/<[^>]*>/g, " ");
|
|
64
|
+
|
|
65
|
+
// HTML entity decoding
|
|
66
|
+
s = s
|
|
67
|
+
.replace(/ /g, " ")
|
|
68
|
+
.replace(/&/g, "&")
|
|
69
|
+
.replace(/</g, "<")
|
|
70
|
+
.replace(/>/g, ">")
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, "'")
|
|
73
|
+
.replace(/…/g, "…")
|
|
74
|
+
.replace(/—/g, "—")
|
|
75
|
+
.replace(/–/g, "–");
|
|
76
|
+
|
|
77
|
+
return s
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((line) => line.trimEnd())
|
|
80
|
+
.filter((line, i, arr) => !(line === "" && arr[i - 1] === ""))
|
|
81
|
+
.join("\n")
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-reach — Lightweight web search and extraction extension for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Injects `web_search` and `web_fetch` tools by default:
|
|
5
|
+
* 1. web_search: Keyless Exa MCP web search returning structured snippets with source URLs.
|
|
6
|
+
* 2. web_fetch : Multi-tier web content extraction engine:
|
|
7
|
+
* - Next.js RSC (React Server Components) static data parsing;
|
|
8
|
+
* - Semantic HTML to Markdown conversion;
|
|
9
|
+
* - PDF document detection and text extraction;
|
|
10
|
+
* - Automatic fallback to Jina Reader for JS-rendered pages;
|
|
11
|
+
* - Pretty-printed JSON formatting.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { searchWeb } from "./search.ts";
|
|
18
|
+
import { fetchUrl } from "./page.ts";
|
|
19
|
+
|
|
20
|
+
export { searchWeb } from "./search.ts";
|
|
21
|
+
export { fetchUrl } from "./page.ts";
|
|
22
|
+
|
|
23
|
+
export default function (pi: ExtensionAPI) {
|
|
24
|
+
// Register web_search tool
|
|
25
|
+
pi.registerTool({
|
|
26
|
+
name: "web_search",
|
|
27
|
+
label: "Web Search",
|
|
28
|
+
description:
|
|
29
|
+
"Search the web using Exa MCP without an API key. Returns high-quality search results with titles, source URLs, and text snippets. Ideal for real-time information, latest news, and research; use web_fetch on result URLs to inspect full pages.",
|
|
30
|
+
parameters: Type.Object({
|
|
31
|
+
query: Type.String({ minLength: 1, description: "Search query, as specific as possible." }),
|
|
32
|
+
}),
|
|
33
|
+
async execute(_toolCallId, params, signal) {
|
|
34
|
+
const { text, provider } = await searchWeb(params.query, signal);
|
|
35
|
+
return { content: [{ type: "text", text }], details: { provider } };
|
|
36
|
+
},
|
|
37
|
+
renderCall(args, theme, context) {
|
|
38
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
39
|
+
const { query } = args as { query?: string };
|
|
40
|
+
const display = query && query.length > 60 ? query.slice(0, 57) + "..." : query || "";
|
|
41
|
+
text.setText(
|
|
42
|
+
theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `"${display}"`),
|
|
43
|
+
);
|
|
44
|
+
return text;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Register web_fetch tool
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
name: "web_fetch",
|
|
51
|
+
label: "Web Fetch",
|
|
52
|
+
description:
|
|
53
|
+
"Intelligently fetch and extract content from a web page. Automatically extracts main content and converts to structured Markdown (supports Next.js RSC parsing, PDF extraction, semantic HTML-to-Markdown, and falls back to Jina Reader for JS-rendered pages). Suitable for in-depth reading of documentation and web pages.",
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
url: Type.String({ description: "Target http(s) URL." }),
|
|
56
|
+
mode: Type.Optional(
|
|
57
|
+
Type.Enum(
|
|
58
|
+
{ readable: "readable", raw: "raw" },
|
|
59
|
+
{ description: "readable: intelligently extract Markdown (default); raw: return raw content." },
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
}),
|
|
63
|
+
async execute(_toolCallId, params, signal) {
|
|
64
|
+
const mode = params.mode as "readable" | "raw" | undefined;
|
|
65
|
+
const { text, title, contentType, status, engine } = await fetchUrl(params.url, { mode }, signal);
|
|
66
|
+
const header = title ? `# ${title}\n\nSource: ${params.url} (Engine: ${engine})\n\n---\n\n` : "";
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: header + text }],
|
|
69
|
+
details: { contentType, status, engine, title, chars: text.length },
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
renderCall(args, theme, context) {
|
|
73
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
74
|
+
const { url } = args as { url?: string };
|
|
75
|
+
const display = url && url.length > 70 ? url.slice(0, 67) + "..." : url || "";
|
|
76
|
+
text.setText(
|
|
77
|
+
theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", display),
|
|
78
|
+
);
|
|
79
|
+
return text;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { DEFAULT_USER_AGENT, envNum, truncate, withTimeout } from "./transport.ts";
|
|
2
|
+
import { extractRSCContent } from "./rsc.ts";
|
|
3
|
+
import { extractTitleFromHtml, htmlToMarkdown, isLikelyJSRendered } from "./html.ts";
|
|
4
|
+
import { fetchViaJinaReader } from "./reader.ts";
|
|
5
|
+
import { extractPdfBuffer, isPdfUrl } from "./pdf.ts";
|
|
6
|
+
|
|
7
|
+
const MIN_USEFUL_CONTENT_LENGTH = 300;
|
|
8
|
+
|
|
9
|
+
export async function fetchUrl(
|
|
10
|
+
url: string,
|
|
11
|
+
opts: { mode?: "readable" | "raw" } = {},
|
|
12
|
+
signal?: AbortSignal,
|
|
13
|
+
): Promise<{ text: string; title: string; contentType: string; status: number; engine: string }> {
|
|
14
|
+
const mode = opts.mode ?? "readable";
|
|
15
|
+
const { ac, done } = withTimeout(
|
|
16
|
+
envNum("SRP_WEB_FETCH_TIMEOUT_MS", 20_000),
|
|
17
|
+
"Web fetch timed out",
|
|
18
|
+
signal,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
redirect: "follow",
|
|
24
|
+
headers: {
|
|
25
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
26
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf,text/plain,*/*;q=0.8",
|
|
27
|
+
},
|
|
28
|
+
signal: ac.signal,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
36
|
+
const maxBytes = envNum("SRP_WEB_FETCH_MAX_BYTES", 4 * 1024 * 1024);
|
|
37
|
+
const arrayBuf = await res.arrayBuffer();
|
|
38
|
+
|
|
39
|
+
if (arrayBuf.byteLength > maxBytes) {
|
|
40
|
+
throw new Error(`Content exceeds ${Math.round((maxBytes / 1024 / 1024) * 10) / 10} MiB limit.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 1. PDF extraction
|
|
44
|
+
if (isPdfUrl(url, contentType)) {
|
|
45
|
+
const { text, title } = await extractPdfBuffer(arrayBuf, url);
|
|
46
|
+
return { text: truncate(text), title, contentType, status: res.status, engine: "unpdf" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = Buffer.from(arrayBuf).toString("utf8");
|
|
50
|
+
|
|
51
|
+
// 2. Raw mode
|
|
52
|
+
if (mode === "raw") {
|
|
53
|
+
return {
|
|
54
|
+
text: truncate(body),
|
|
55
|
+
title: extractTitleFromHtml(body) || url,
|
|
56
|
+
contentType,
|
|
57
|
+
status: res.status,
|
|
58
|
+
engine: "raw",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. JSON formatting
|
|
63
|
+
if (contentType.includes("json")) {
|
|
64
|
+
try {
|
|
65
|
+
const text = JSON.stringify(JSON.parse(body), null, 2);
|
|
66
|
+
return { text: truncate(text), title: "JSON Response", contentType, status: res.status, engine: "json" };
|
|
67
|
+
} catch {
|
|
68
|
+
return { text: truncate(body), title: "JSON", contentType, status: res.status, engine: "text" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. HTML processing
|
|
73
|
+
if (contentType.includes("html") || contentType.includes("xhtml")) {
|
|
74
|
+
// 4.1 Attempt Next.js RSC extraction
|
|
75
|
+
const rsc = extractRSCContent(body);
|
|
76
|
+
if (rsc && rsc.content.length >= MIN_USEFUL_CONTENT_LENGTH) {
|
|
77
|
+
return {
|
|
78
|
+
text: truncate(rsc.content),
|
|
79
|
+
title: rsc.title || extractTitleFromHtml(body),
|
|
80
|
+
contentType,
|
|
81
|
+
status: res.status,
|
|
82
|
+
engine: "nextjs-rsc",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4.2 Semantic HTML to Markdown
|
|
87
|
+
const title = extractTitleFromHtml(body);
|
|
88
|
+
const markdown = htmlToMarkdown(body);
|
|
89
|
+
|
|
90
|
+
if (markdown.length >= MIN_USEFUL_CONTENT_LENGTH && !isLikelyJSRendered(body)) {
|
|
91
|
+
return {
|
|
92
|
+
text: truncate(markdown),
|
|
93
|
+
title,
|
|
94
|
+
contentType,
|
|
95
|
+
status: res.status,
|
|
96
|
+
engine: "html-markdown",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4.3 Dynamic JS page or short content: attempt Jina Reader fallback
|
|
101
|
+
const jina = await fetchViaJinaReader(url, signal);
|
|
102
|
+
if (jina && jina.text.length >= 100) {
|
|
103
|
+
return {
|
|
104
|
+
text: truncate(jina.text),
|
|
105
|
+
title: jina.title || title,
|
|
106
|
+
contentType: "text/markdown",
|
|
107
|
+
status: res.status,
|
|
108
|
+
engine: "jina-reader",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
text: truncate(markdown || body.slice(0, 4000)),
|
|
114
|
+
title,
|
|
115
|
+
contentType,
|
|
116
|
+
status: res.status,
|
|
117
|
+
engine: "html-fallback",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 5. Plain text and other types
|
|
122
|
+
return {
|
|
123
|
+
text: truncate(body),
|
|
124
|
+
title: url.split("/").pop() || "Plain Text",
|
|
125
|
+
contentType,
|
|
126
|
+
status: res.status,
|
|
127
|
+
engine: "text",
|
|
128
|
+
};
|
|
129
|
+
} finally {
|
|
130
|
+
done();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function isPdfUrl(url: string, contentType?: string): boolean {
|
|
2
|
+
if (contentType?.includes("application/pdf")) return true;
|
|
3
|
+
try {
|
|
4
|
+
return new URL(url).pathname.toLowerCase().endsWith(".pdf");
|
|
5
|
+
} catch {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function extractPdfBuffer(
|
|
11
|
+
buffer: ArrayBuffer,
|
|
12
|
+
url: string,
|
|
13
|
+
): Promise<{ text: string; title: string }> {
|
|
14
|
+
try {
|
|
15
|
+
const { getDocumentProxy } = await import("unpdf");
|
|
16
|
+
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
|
17
|
+
const maxPages = Math.min(pdf.numPages, 80);
|
|
18
|
+
const pages: string[] = [];
|
|
19
|
+
|
|
20
|
+
for (let i = 1; i <= maxPages; i++) {
|
|
21
|
+
const page = await pdf.getPage(i);
|
|
22
|
+
const textContent = await page.getTextContent();
|
|
23
|
+
const pageText = textContent.items
|
|
24
|
+
.map((item: unknown) => (item as { str?: string }).str || "")
|
|
25
|
+
.join(" ")
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim();
|
|
28
|
+
if (pageText) pages.push(`--- Page ${i} ---\n${pageText}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const title = new URL(url).pathname.split("/").pop() || "PDF Document";
|
|
32
|
+
const text = `# ${title}\n\nPages: ${pdf.numPages}\n\n${pages.join("\n\n")}`;
|
|
33
|
+
return { text, title };
|
|
34
|
+
} catch {
|
|
35
|
+
// Fallback plain text extraction
|
|
36
|
+
const text = Buffer.from(buffer).toString("latin1").replace(/[^\x20-\x7E\n\r\t]/g, " ");
|
|
37
|
+
return {
|
|
38
|
+
text: `# PDF Document\n\n${text.slice(0, 8000)}`,
|
|
39
|
+
title: "PDF Document",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DEFAULT_USER_AGENT, withTimeout } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
const JINA_READER_BASE = "https://r.jina.ai/";
|
|
4
|
+
|
|
5
|
+
export async function fetchViaJinaReader(
|
|
6
|
+
url: string,
|
|
7
|
+
signal?: AbortSignal,
|
|
8
|
+
): Promise<{ text: string; title: string } | null> {
|
|
9
|
+
const { ac, done } = withTimeout(30_000, "Jina Reader timed out", signal);
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${JINA_READER_BASE}${url}`, {
|
|
12
|
+
headers: {
|
|
13
|
+
Accept: "text/markdown",
|
|
14
|
+
"X-No-Cache": "true",
|
|
15
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
16
|
+
},
|
|
17
|
+
signal: ac.signal,
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) return null;
|
|
20
|
+
|
|
21
|
+
const raw = await res.text();
|
|
22
|
+
const contentIndex = raw.indexOf("Markdown Content:");
|
|
23
|
+
const markdown = contentIndex >= 0 ? raw.slice(contentIndex + 17).trim() : raw.trim();
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
markdown.length < 80 ||
|
|
27
|
+
markdown.startsWith("Loading...") ||
|
|
28
|
+
markdown.startsWith("Please enable JavaScript")
|
|
29
|
+
) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const titleMatch = markdown.match(/^#+\s+(.+)$/m);
|
|
34
|
+
const title = titleMatch?.[1]?.trim() || "";
|
|
35
|
+
|
|
36
|
+
return { text: markdown, title };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
} finally {
|
|
40
|
+
done();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export function extractRSCContent(html: string): { title: string; content: string } | null {
|
|
2
|
+
if (!html.includes("self.__next_f.push")) return null;
|
|
3
|
+
|
|
4
|
+
const chunkMap = new Map<string, string>();
|
|
5
|
+
const scriptRegex = /<script>self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)<\/script>/g;
|
|
6
|
+
|
|
7
|
+
for (const match of html.matchAll(scriptRegex)) {
|
|
8
|
+
let content: string;
|
|
9
|
+
try {
|
|
10
|
+
content = JSON.parse('"' + match[1] + '"');
|
|
11
|
+
} catch {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
for (const line of content.split("\n")) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
const colonIdx = line.indexOf(":");
|
|
17
|
+
if (colonIdx <= 0 || colonIdx > 4) continue;
|
|
18
|
+
const id = line.slice(0, colonIdx);
|
|
19
|
+
if (!/^[0-9a-f]+$/i.test(id)) continue;
|
|
20
|
+
const payload = line.slice(colonIdx + 1);
|
|
21
|
+
if (!payload) continue;
|
|
22
|
+
const existing = chunkMap.get(id);
|
|
23
|
+
if (!existing || payload.length > existing.length) {
|
|
24
|
+
chunkMap.set(id, payload);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (chunkMap.size === 0) return null;
|
|
30
|
+
|
|
31
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
32
|
+
const title = titleMatch?.[1]?.split("|")[0]?.trim() || "";
|
|
33
|
+
|
|
34
|
+
const parsedCache = new Map<string, unknown>();
|
|
35
|
+
function getParsedChunk(id: string): unknown | null {
|
|
36
|
+
if (parsedCache.has(id)) return parsedCache.get(id);
|
|
37
|
+
const chunk = chunkMap.get(id);
|
|
38
|
+
if (!chunk || !chunk.startsWith("[")) {
|
|
39
|
+
parsedCache.set(id, null);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(chunk);
|
|
44
|
+
parsedCache.set(id, parsed);
|
|
45
|
+
return parsed;
|
|
46
|
+
} catch {
|
|
47
|
+
parsedCache.set(id, null);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const visitedRefs = new Set<string>();
|
|
53
|
+
|
|
54
|
+
function extractNode(node: unknown, ctx = { inCode: false }): string {
|
|
55
|
+
if (node === null || node === undefined) return "";
|
|
56
|
+
if (typeof node === "string") {
|
|
57
|
+
const refMatch = node.match(/^\$L([0-9a-f]+)$/i);
|
|
58
|
+
if (refMatch) {
|
|
59
|
+
const refId = refMatch[1];
|
|
60
|
+
if (visitedRefs.has(refId)) return "";
|
|
61
|
+
visitedRefs.add(refId);
|
|
62
|
+
const refNode = getParsedChunk(refId);
|
|
63
|
+
const result = refNode ? extractNode(refNode, ctx) : "";
|
|
64
|
+
visitedRefs.delete(refId);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
if (!ctx.inCode && (node === "$undefined" || node === "$" || /^\$[A-Z]/.test(node))) {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
return node.trim() ? node : "";
|
|
71
|
+
}
|
|
72
|
+
if (typeof node === "number") return String(node);
|
|
73
|
+
if (typeof node === "boolean") return "";
|
|
74
|
+
if (!Array.isArray(node)) return "";
|
|
75
|
+
|
|
76
|
+
if (node[0] === "$" && typeof node[1] === "string") {
|
|
77
|
+
const tag = node[1] as string;
|
|
78
|
+
const props = (node[3] || {}) as Record<string, unknown>;
|
|
79
|
+
const skipTags = [
|
|
80
|
+
"script", "style", "svg", "path", "circle", "link", "meta",
|
|
81
|
+
"template", "button", "input", "nav", "footer", "aside",
|
|
82
|
+
];
|
|
83
|
+
if (skipTags.includes(tag)) return "";
|
|
84
|
+
|
|
85
|
+
if (tag.startsWith("$L")) {
|
|
86
|
+
const refId = tag.slice(2);
|
|
87
|
+
if (visitedRefs.has(refId)) return "";
|
|
88
|
+
if (props.baseId && props.children) {
|
|
89
|
+
return `## ${String(props.children)}\n\n`;
|
|
90
|
+
}
|
|
91
|
+
visitedRefs.add(refId);
|
|
92
|
+
const refNode = getParsedChunk(refId);
|
|
93
|
+
let result = "";
|
|
94
|
+
if (refNode) result = extractNode(refNode, ctx);
|
|
95
|
+
else if (props.children) result = extractNode(props.children, ctx);
|
|
96
|
+
visitedRefs.delete(refId);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const children = props.children;
|
|
101
|
+
const content = children ? extractNode(children, ctx) : "";
|
|
102
|
+
|
|
103
|
+
switch (tag) {
|
|
104
|
+
case "h1": return `# ${content.trim()}\n\n`;
|
|
105
|
+
case "h2": return `## ${content.trim()}\n\n`;
|
|
106
|
+
case "h3": return `### ${content.trim()}\n\n`;
|
|
107
|
+
case "h4": return `#### ${content.trim()}\n\n`;
|
|
108
|
+
case "h5": return `##### ${content.trim()}\n\n`;
|
|
109
|
+
case "h6": return `###### ${content.trim()}\n\n`;
|
|
110
|
+
case "p": return `${content.trim()}\n\n`;
|
|
111
|
+
case "code": {
|
|
112
|
+
const cc = children ? extractNode(children, { inCode: true }) : "";
|
|
113
|
+
return ctx.inCode ? cc : `\`${cc}\``;
|
|
114
|
+
}
|
|
115
|
+
case "pre": {
|
|
116
|
+
const pc = children ? extractNode(children, { inCode: true }) : "";
|
|
117
|
+
return "```\n" + pc + "\n```\n\n";
|
|
118
|
+
}
|
|
119
|
+
case "strong": case "b": return `**${content}**`;
|
|
120
|
+
case "em": case "i": return `*${content}*`;
|
|
121
|
+
case "li": return `- ${content.trim()}\n`;
|
|
122
|
+
case "ul": case "ol": return content + "\n";
|
|
123
|
+
case "blockquote": return `> ${content.trim()}\n\n`;
|
|
124
|
+
case "a": {
|
|
125
|
+
const href = props.href as string | undefined;
|
|
126
|
+
return href && !href.startsWith("#") ? `[${content}](${href})` : content;
|
|
127
|
+
}
|
|
128
|
+
default: return content;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (node as unknown[]).map((n) => extractNode(n, ctx)).join("");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const contentParts: { order: number; text: string }[] = [];
|
|
136
|
+
for (const [id] of chunkMap) {
|
|
137
|
+
const parsed = getParsedChunk(id);
|
|
138
|
+
if (!parsed) continue;
|
|
139
|
+
visitedRefs.clear();
|
|
140
|
+
const text = extractNode(parsed);
|
|
141
|
+
if (text.trim().length > 50 && !text.includes("404") && !text.includes("not found")) {
|
|
142
|
+
contentParts.push({ order: parseInt(id, 16) || 0, text: text.trim() });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (contentParts.length === 0) return null;
|
|
147
|
+
contentParts.sort((a, b) => a.order - b.order);
|
|
148
|
+
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
const uniqueParts: string[] = [];
|
|
151
|
+
for (const part of contentParts) {
|
|
152
|
+
const key = part.text.slice(0, 150);
|
|
153
|
+
if (!seen.has(key)) {
|
|
154
|
+
seen.add(key);
|
|
155
|
+
uniqueParts.push(part.text);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const content = uniqueParts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
160
|
+
return content.length > 100 ? { title, content } : null;
|
|
161
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { envNum, truncate, withTimeout } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
4
|
+
const EXA_SEARCH_TOOL = "web_search_exa";
|
|
5
|
+
|
|
6
|
+
interface ExaMcpResponse {
|
|
7
|
+
result?: {
|
|
8
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
9
|
+
isError?: boolean;
|
|
10
|
+
};
|
|
11
|
+
error?: { code?: number; message?: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseExaResponse(body: string): ExaMcpResponse | undefined {
|
|
15
|
+
for (const line of body.split("\n")) {
|
|
16
|
+
if (!line.startsWith("data:")) continue;
|
|
17
|
+
const payload = line.slice(5).trim();
|
|
18
|
+
if (!payload) continue;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(payload) as ExaMcpResponse;
|
|
21
|
+
if (parsed.result || parsed.error) return parsed;
|
|
22
|
+
} catch {
|
|
23
|
+
/* continue to next SSE event */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(body) as ExaMcpResponse;
|
|
28
|
+
return parsed.result || parsed.error ? parsed : undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function searchWeb(
|
|
35
|
+
query: string,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
): Promise<{ text: string; provider: string }> {
|
|
38
|
+
query = query.trim();
|
|
39
|
+
if (!query) throw new Error("Search query cannot be empty.");
|
|
40
|
+
const { ac, done } = withTimeout(
|
|
41
|
+
envNum("SRP_WEB_SEARCH_TIMEOUT_MS", 60_000),
|
|
42
|
+
"Search request timed out",
|
|
43
|
+
signal,
|
|
44
|
+
);
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${EXA_MCP_URL}?tools=${EXA_SEARCH_TOOL}`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
Accept: "application/json, text/event-stream",
|
|
51
|
+
"x-exa-source": "web-reach",
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
jsonrpc: "2.0",
|
|
55
|
+
id: 1,
|
|
56
|
+
method: "tools/call",
|
|
57
|
+
params: { name: EXA_SEARCH_TOOL, arguments: { query, numResults: 6 } },
|
|
58
|
+
}),
|
|
59
|
+
signal: ac.signal,
|
|
60
|
+
});
|
|
61
|
+
const body = await res.text();
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const hint = res.status === 429 ? " (Exa free tier rate limit reached; please try again later)" : "";
|
|
64
|
+
throw new Error(`Exa MCP HTTP ${res.status}${hint}: ${body.slice(0, 300)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const payload = parseExaResponse(body);
|
|
68
|
+
if (!payload) throw new Error("Exa MCP returned an unparseable response.");
|
|
69
|
+
if (payload.error) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Exa MCP error${payload.error.code ? ` ${payload.error.code}` : ""}: ${payload.error.message ?? "Unknown error"}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const text = payload.result?.content
|
|
76
|
+
?.find((item) => item.type === "text" && item.text?.trim())
|
|
77
|
+
?.text?.trim();
|
|
78
|
+
if (payload.result?.isError || !text) {
|
|
79
|
+
throw new Error(text || "Exa MCP returned empty content.");
|
|
80
|
+
}
|
|
81
|
+
return { text: truncate(text), provider: "exa-mcp" };
|
|
82
|
+
} finally {
|
|
83
|
+
done();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const DEFAULT_USER_AGENT =
|
|
2
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 web-reach/2.0";
|
|
3
|
+
|
|
4
|
+
const MAX_RETURN_CHARS = 35_000;
|
|
5
|
+
|
|
6
|
+
export function envNum(name: string, fallback: number): number {
|
|
7
|
+
const n = Number(process.env[name]);
|
|
8
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ============================ Timeout & Helpers ============================
|
|
12
|
+
|
|
13
|
+
export function withTimeout(
|
|
14
|
+
ms: number,
|
|
15
|
+
label: string,
|
|
16
|
+
signal?: AbortSignal,
|
|
17
|
+
): { ac: AbortController; done: () => void } {
|
|
18
|
+
const ac = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => ac.abort(new Error(`${label} timed out (${ms / 1000}s)`)), ms);
|
|
20
|
+
const onAbort = () => ac.abort(signal?.reason);
|
|
21
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
22
|
+
if (signal?.aborted) onAbort();
|
|
23
|
+
return {
|
|
24
|
+
ac,
|
|
25
|
+
done: () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener("abort", onAbort);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function truncate(text: string, limit = MAX_RETURN_CHARS): string {
|
|
33
|
+
if (text.length <= limit) return text;
|
|
34
|
+
return text.slice(0, limit) + `\n\n...(content truncated to ${limit} characters)`;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rol1n/web-reach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "web-reach extension from pi-kit",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/RolinShmily/pi-kit.git",
|
|
12
|
+
"directory": "extensions/web-reach"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/RolinShmily/pi-kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/RolinShmily/pi-kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions/web-reach",
|
|
26
|
+
"extensions/web-reach/README.md"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "*",
|
|
30
|
+
"@earendil-works/pi-agent-core": "*",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./extensions/web-reach/index.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [],
|
|
40
|
+
"prompts": [],
|
|
41
|
+
"themes": []
|
|
42
|
+
}
|
|
43
|
+
}
|